diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 489e4aaa7..7e1212b53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,15 +14,15 @@ jobs: runs-on: ubuntu-20.04 container: node:14.16-alpine steps: - - name: checkout + - name: Checkout uses: actions/checkout@v2 - - name: install dependencies + - name: Install dependencies env: HUSKY_SKIP_INSTALL: 1 run: yarn - - name: lint + - name: Lint run: yarn lint - - name: build + - name: Build run: yarn build build_and_push: @@ -70,7 +70,7 @@ jobs: ghcr.io/sct/overseerr:develop ghcr.io/sct/overseerr:${{ github.sha }} cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-new + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max - # Temporary fix # https://github.com/docker/build-push-action/issues/252 # https://github.com/moby/buildkit/issues/1896 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 000000000..9f05f86e8 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,44 @@ +name: Overseerr Preview + +on: + push: + tags: + - 'preview-*' + +jobs: + build_and_push: + name: Build & Publish Docker Preview Images + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Get the version + id: get_version + run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Log in to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + build-args: | + COMMIT_TAG=${{ github.sha }} + tags: | + sctx/overseerr:${{ steps.get_version.outputs.VERSION }} + ghcr.io/sct/overseerr:${{ steps.get_version.outputs.VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62b2ecb1e..8ff0fbb77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,16 +11,17 @@ jobs: runs-on: ubuntu-20.04 container: node:14.16-alpine steps: - - name: checkout + - name: Checkout uses: actions/checkout@v2 - - name: install dependencies + - name: Install dependencies env: HUSKY_SKIP_INSTALL: 1 run: yarn - - name: lint + - name: Lint run: yarn lint - - name: build + - name: Build run: yarn build + semantic-release: name: Tag and release latest version needs: test @@ -57,6 +58,7 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: npx semantic-release + build-snap: name: Build Snap Package (${{ matrix.architecture }}) needs: semantic-release @@ -77,7 +79,6 @@ jobs: run: git checkout master - name: Pull latest changes run: git pull - - name: Prepare id: prepare run: | @@ -87,35 +88,31 @@ jobs: else echo ::set-output name=RELEASE::edge fi - - name: Set Up QEMU uses: docker/setup-qemu-action@v1 with: image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde - - name: Build Snap Package uses: diddlesnaps/snapcraft-multiarch-action@v1 id: build with: architecture: ${{ matrix.architecture }} - - name: Upload Snap Package uses: actions/upload-artifact@v2 with: name: overseerr-snap-package-${{ matrix.architecture }} path: ${{ steps.build.outputs.snap }} - - name: Review Snap Package uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0 with: snap: ${{ steps.build.outputs.snap }} - - name: Publish Snap Package uses: snapcore/action-publish@v1 with: store_login: ${{ secrets.SNAP_LOGIN }} snap: ${{ steps.build.outputs.snap }} release: ${{ steps.prepare.outputs.RELEASE }} + discord: name: Send Discord Notification needs: semantic-release @@ -124,7 +121,6 @@ jobs: steps: - name: Get Build Job Status uses: technote-space/workflow-conclusion-action@v2.1.5 - - name: Combine Job Status id: status run: | @@ -134,7 +130,6 @@ jobs: else echo ::set-output name=status::$WORKFLOW_CONCLUSION fi - - name: Post Status to Discord uses: sarisia/actions-status-discord@v1 with: diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index ea7f92f9e..b0b5a3992 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -22,15 +22,15 @@ jobs: runs-on: ubuntu-20.04 container: node:14.16-alpine steps: - - name: checkout + - name: Checkout uses: actions/checkout@v2 - - name: install dependencies + - name: Install dependencies env: HUSKY_SKIP_INSTALL: 1 run: yarn - - name: lint + - name: Lint run: yarn lint - - name: build + - name: Build run: yarn build build-snap: diff --git a/Dockerfile b/Dockerfile index 9a5af8ca7..eda37b312 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,30 @@ FROM node:14.16-alpine AS BUILD_IMAGE +WORKDIR /app + ARG TARGETPLATFORM ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} -ARG COMMIT_TAG -ENV COMMIT_TAG=${COMMIT_TAG} - -COPY . /app -WORKDIR /app - RUN \ case "${TARGETPLATFORM}" in \ 'linux/arm64') apk add --no-cache python make g++ ;; \ 'linux/arm/v7') apk add --no-cache python make g++ ;; \ esac -RUN yarn --frozen-lockfile --network-timeout 1000000 && \ - yarn build +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --network-timeout 1000000 + +COPY . ./ + +ARG COMMIT_TAG +ENV COMMIT_TAG=${COMMIT_TAG} + +RUN yarn build # remove development dependencies RUN yarn install --production --ignore-scripts --prefer-offline -RUN rm -rf src && \ - rm -rf server +RUN rm -rf src server RUN touch config/DOCKER @@ -31,11 +33,12 @@ RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json FROM node:14.16-alpine +WORKDIR /app + RUN apk add --no-cache tzdata tini # copy from build image -COPY --from=BUILD_IMAGE /app /app -WORKDIR /app +COPY --from=BUILD_IMAGE /app ./ ENTRYPOINT [ "/sbin/tini", "--" ] CMD [ "yarn", "start" ] diff --git a/overseerr-api.yml b/overseerr-api.yml index 08bf1b5ca..637af162c 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -103,8 +103,10 @@ components: properties: apiKey: type: string - example: 'anapikey' readOnly: true + appLanguage: + type: string + example: en applicationTitle: type: string example: Overseerr @@ -126,6 +128,9 @@ components: localLogin: type: boolean example: true + newPlexLogin: + type: boolean + example: true defaultPermissions: type: number example: 32 @@ -1128,6 +1133,15 @@ components: properties: webhookUrl: type: string + WebPushSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 WebhookSettings: type: object properties: @@ -1142,6 +1156,8 @@ components: properties: webhookUrl: type: string + authHeader: + type: string jsonPayload: type: string TelegramSettings: @@ -1196,6 +1212,22 @@ components: type: string priority: type: number + LunaSeaSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + profileName: + type: string NotificationEmailSettings: type: object properties: @@ -1223,6 +1255,12 @@ components: secure: type: boolean example: false + ignoreTls: + type: boolean + example: false + requireTls: + type: boolean + example: false authUser: type: string nullable: true @@ -2397,22 +2435,22 @@ paths: responses: '204': description: Test notification attempted - /settings/notifications/telegram: + /settings/notifications/lunasea: get: - summary: Get Telegram notification settings - description: Returns current Telegram notification settings in a JSON object. + summary: Get LunaSea notification settings + description: Returns current LunaSea notification settings in a JSON object. tags: - settings responses: '200': - description: Returned Telegram settings + description: Returned LunaSea settings content: application/json: schema: - $ref: '#/components/schemas/TelegramSettings' + $ref: '#/components/schemas/LunaSeaSettings' post: - summary: Update Telegram notification settings - description: Update Telegram notification settings with the provided values. + summary: Update LunaSea notification settings + description: Updates LunaSea notification settings with the provided values. tags: - settings requestBody: @@ -2420,18 +2458,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TelegramSettings' + $ref: '#/components/schemas/LunaSeaSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: - $ref: '#/components/schemas/TelegramSettings' - /settings/notifications/telegram/test: + $ref: '#/components/schemas/LunaSeaSettings' + /settings/notifications/lunasea/test: post: - summary: Test Telegram settings - description: Sends a test notification to the Telegram agent. + summary: Test LunaSea settings + description: Sends a test notification to the LunaSea agent. tags: - settings requestBody: @@ -2439,7 +2477,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TelegramSettings' + $ref: '#/components/schemas/LunaSeaSettings' responses: '204': description: Test notification attempted @@ -2581,6 +2619,98 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/telegram: + get: + summary: Get Telegram notification settings + description: Returns current Telegram notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Telegram settings + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + post: + summary: Update Telegram notification settings + description: Update Telegram notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + /settings/notifications/telegram/test: + post: + summary: Test Telegram settings + description: Sends a test notification to the Telegram agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/webpush: + get: + summary: Get Web Push notification settings + description: Returns current Web Push notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned web push settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + post: + summary: Update Web Push notification settings + description: Updates Web Push notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + /settings/notifications/webpush/test: + post: + summary: Test Web Push settings + description: Sends a test notification to the Web Push agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '204': + description: Test notification attempted /settings/notifications/webhook: get: summary: Get webhook notification settings @@ -2903,6 +3033,32 @@ paths: type: array items: $ref: '#/components/schemas/User' + /user/registerPushSubscription: + post: + summary: Register a web push /user/registerPushSubscription + description: Registers a web push subscription for the logged-in user + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + auth: + type: string + p256dh: + type: string + required: + - endpoint + - auth + - p256dh + responses: + '204': + description: Successfully registered push subscription /user/{userId}: get: summary: Get user by ID diff --git a/package.json b/package.json index 0c7a91b34..564a5e41c 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ }, "license": "MIT", "dependencies": { - "@headlessui/react": "^1.0.0", + "@headlessui/react": "^1.1.1", "@heroicons/react": "^1.0.1", "@supercharge/request-ip": "^1.1.2", "@svgr/webpack": "^5.5.0", - "@tanem/react-nprogress": "^3.0.62", + "@tanem/react-nprogress": "^3.0.64", "ace-builds": "^1.4.12", "axios": "^0.21.1", "bcrypt": "^5.0.1", @@ -30,35 +30,34 @@ "connect-typeorm": "^1.1.4", "cookie-parser": "^1.4.5", "copy-to-clipboard": "^3.3.1", - "country-flag-icons": "^1.2.9", + "country-flag-icons": "^1.2.10", "csurf": "^1.11.0", "email-templates": "^8.0.4", "express": "^4.17.1", - "express-openapi-validator": "^4.12.7", + "express-openapi-validator": "^4.12.9", "express-rate-limit": "^5.2.6", "express-session": "^1.17.1", "formik": "^2.2.6", - "gravatar-url": "^3.1.0", + "gravatar-url": "3.1.0", "intl": "^1.2.5", "lodash": "^4.17.21", "next": "10.1.3", "node-cache": "^5.1.2", "node-schedule": "^2.0.0", - "nodemailer": "^6.5.0", - "nookies": "^2.5.2", - "openpgp": "^5.0.0-1", + "nodemailer": "^6.6.0", + "openpgp": "^5.0.0-2", "plex-api": "^5.3.1", "pug": "^3.0.2", "react": "17.0.2", "react-ace": "^9.3.0", "react-animate-height": "^2.0.23", "react-dom": "17.0.2", - "react-intersection-observer": "^8.31.0", - "react-intl": "5.15.8", - "react-markdown": "^6.0.0", + "react-intersection-observer": "^8.31.1", + "react-intl": "5.17.4", + "react-markdown": "^6.0.1", "react-select": "^4.3.0", "react-spring": "^8.0.27", - "react-toast-notifications": "^2.4.3", + "react-toast-notifications": "^2.4.4", "react-transition-group": "^4.4.1", "react-truncate-markup": "^5.1.0", "react-use-clipboard": "1.0.7", @@ -66,17 +65,18 @@ "secure-random-password": "^0.2.2", "sqlite3": "^5.0.2", "swagger-ui-express": "^4.1.6", - "swr": "^0.5.5", + "swr": "^0.5.6", "typeorm": "^0.2.32", "uuid": "^8.3.2", + "web-push": "^3.4.4", "winston": "^3.3.3", - "winston-daily-rotate-file": "^4.5.2", + "winston-daily-rotate-file": "^4.5.3", "xml2js": "^0.4.23", "yamljs": "^0.3.0", "yup": "^0.32.9" }, "devDependencies": { - "@babel/cli": "^7.13.14", + "@babel/cli": "^7.13.16", "@commitlint/cli": "^12.1.1", "@commitlint/config-conventional": "^12.1.1", "@semantic-release/changelog": "^5.0.1", @@ -96,10 +96,10 @@ "@types/express-rate-limit": "^5.1.1", "@types/express-session": "^1.17.3", "@types/lodash": "^4.14.168", - "@types/node": "^14.14.41", + "@types/node": "^15.0.1", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.1", - "@types/react": "^17.0.3", + "@types/react": "^17.0.4", "@types/react-dom": "^17.0.3", "@types/react-select": "^4.0.15", "@types/react-toast-notifications": "^2.4.0", @@ -107,6 +107,7 @@ "@types/secure-random-password": "^0.2.0", "@types/swagger-ui-express": "^4.1.2", "@types/uuid": "^8.3.0", + "@types/web-push": "^3.3.0", "@types/xml2js": "^0.4.8", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", @@ -118,9 +119,9 @@ "commitizen": "^4.2.3", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.24.0", - "eslint-config-prettier": "^8.2.0", - "eslint-plugin-formatjs": "^2.14.6", + "eslint": "^7.25.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-formatjs": "^2.14.10", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-react": "^7.23.2", @@ -129,11 +130,11 @@ "husky": "4.3.8", "lint-staged": "^10.5.4", "nodemon": "^2.0.7", - "postcss": "^8.2.10", + "postcss": "^8.2.13", "prettier": "^2.2.1", "semantic-release": "^17.4.2", "semantic-release-docker-buildx": "^1.0.1", - "tailwindcss": "^2.1.1", + "tailwindcss": "^2.1.2", "ts-node": "^9.1.1", "typescript": "^4.2.4" }, diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 692f01a85..f246f9fa2 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-192x192_maskable.png b/public/android-chrome-192x192_maskable.png new file mode 100644 index 000000000..ecdd73670 Binary files /dev/null and b/public/android-chrome-192x192_maskable.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index 34d1f9e1e..4910ced2d 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/android-chrome-512x512_maskable.png b/public/android-chrome-512x512_maskable.png new file mode 100644 index 000000000..475cf85e9 Binary files /dev/null and b/public/android-chrome-512x512_maskable.png differ diff --git a/public/clock-icon-192x192.png b/public/clock-icon-192x192.png new file mode 100644 index 000000000..d7e57dd0a Binary files /dev/null and b/public/clock-icon-192x192.png differ diff --git a/public/cog-icon-192x192.png b/public/cog-icon-192x192.png new file mode 100644 index 000000000..99d114059 Binary files /dev/null and b/public/cog-icon-192x192.png differ diff --git a/public/images/radarr_logo.svg b/public/images/radarr_logo.svg deleted file mode 100644 index 231b9f936..000000000 --- a/public/images/radarr_logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/images/sonarr_logo.svg b/public/images/sonarr_logo.svg deleted file mode 100644 index 2175728dd..000000000 --- a/public/images/sonarr_logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 000000000..01658360f --- /dev/null +++ b/public/offline.html @@ -0,0 +1,69 @@ + + + + + + + + You are offline + + + + + +

You are offline

+ + + + + + + diff --git a/public/preview.jpg b/public/preview.jpg index 946ef07a9..e393f1667 100644 Binary files a/public/preview.jpg and b/public/preview.jpg differ diff --git a/public/site.webmanifest b/public/site.webmanifest index 6cd906115..53e89cfc9 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -4,19 +4,77 @@ "start_url": "./", "icons": [ { - "src": "/android-chrome-192x192.png", + "src": "./android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "./android-chrome-192x192_maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/android-chrome-512x512.png", + "src": "./android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "./android-chrome-512x512_maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], - "theme_color": "#2d3748", - "background_color": "#2d3748", - "display": "standalone" + "theme_color": "#1f2937", + "background_color": "#1f2937", + "display": "standalone", + "shortcuts": [ + { + "name": "Discover", + "url": "./", + "icons": [ + { + "src": "./sparkles-icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Requests", + "url": "./requests", + "icons": [ + { + "src": "./clock-icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Profile", + "url": "./profile", + "icons": [ + { + "src": "./user-icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Settings", + "url": "./profile/settings", + "icons": [ + { + "src": "./cog-icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + } + ] } diff --git a/public/sparkles-icon-192x192.png b/public/sparkles-icon-192x192.png new file mode 100644 index 000000000..5a27a8df5 Binary files /dev/null and b/public/sparkles-icon-192x192.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..d6672e609 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,136 @@ +// Incrementing OFFLINE_VERSION will kick off the install event and force +// previously cached resources to be updated from the network. +// This variable is intentionally declared and unused. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const OFFLINE_VERSION = 3; +const CACHE_NAME = "offline"; +// Customize this with a different URL if needed. +const OFFLINE_URL = "/offline.html"; + +self.addEventListener("install", (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE_NAME); + // Setting {cache: 'reload'} in the new request will ensure that the + // response isn't fulfilled from the HTTP cache; i.e., it will be from + // the network. + await cache.add(new Request(OFFLINE_URL, { cache: "reload" })); + })() + ); + // Force the waiting service worker to become the active service worker. + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + // Enable navigation preload if it's supported. + // See https://developers.google.com/web/updates/2017/02/navigation-preload + if ("navigationPreload" in self.registration) { + await self.registration.navigationPreload.enable(); + } + })() + ); + + // Tell the active service worker to take control of the page immediately. + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + // We only want to call event.respondWith() if this is a navigation request + // for an HTML page. + if (event.request.mode === "navigate") { + event.respondWith( + (async () => { + try { + // First, try to use the navigation preload response if it's supported. + const preloadResponse = await event.preloadResponse; + if (preloadResponse) { + return preloadResponse; + } + + // Always try the network first. + const networkResponse = await fetch(event.request); + return networkResponse; + } catch (error) { + // catch is only triggered if an exception is thrown, which is likely + // due to a network error. + // If fetch() returns a valid HTTP response with a response code in + // the 4xx or 5xx range, the catch() will NOT be called. + console.log("Fetch failed; returning offline page instead.", error); + + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(OFFLINE_URL); + return cachedResponse; + } + })() + ); + } +}); + +self.addEventListener('push', (event) => { + const payload = event.data ? event.data.json() : {}; + + const options = { + body: payload.message, + icon: payload.image ? payload.image : 'android-chrome-192x192.png', + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: '2', + actionUrl: payload.actionUrl, + requestId: payload.requestId, + }, + actions: [], + } + + if (payload.actionUrl){ + options.actions.push( + { + action: 'viewmedia', + title: 'View Media', + } + ); + } + + if (payload.notificationType === 'MEDIA_PENDING') { + options.actions.push( + { + action: 'approve', + title: 'Approve', + }, + { + action: 'decline', + title: 'Decline', + } + ); + } + + event.waitUntil( + self.registration.showNotification(payload.subject, options) + ); +}) + +self.addEventListener('notificationclick', (event) => { + const notificationData = event.notification.data; + + event.notification.close(); + + if (event.action === 'viewmedia') { + self.clients.openWindow(notificationData.actionUrl); + } else if (event.action === 'approve') { + fetch(`/api/v1/request/${notificationData.requestId}/approve`, { + method: 'POST', + }); + + self.clients.openWindow(notificationData.actionUrl); + } else if (event.action === 'decline') { + fetch(`/api/v1/request/${notificationData.requestId}/decline`, { + method: 'POST', + }); + + self.clients.openWindow(notificationData.actionUrl); + } else if (notificationData.actionUrl) { + self.clients.openWindow(notificationData.actionUrl); + } +}, false); diff --git a/public/user-icon-192x192.png b/public/user-icon-192x192.png new file mode 100644 index 000000000..945542281 Binary files /dev/null and b/public/user-icon-192x192.png differ diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index be28e35d0..21852f22d 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -282,11 +282,7 @@ export class MediaRequest { media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PARTIALLY_AVAILABLE ) { - if (this.is4k) { - media.status4k = MediaStatus.PROCESSING; - } else { - media.status = MediaStatus.PROCESSING; - } + media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING; mediaRepository.save(media); } @@ -294,11 +290,7 @@ export class MediaRequest { media.mediaType === MediaType.MOVIE && this.status === MediaRequestStatus.DECLINED ) { - if (this.is4k) { - media.status4k = MediaStatus.UNKNOWN; - } else { - media.status = MediaStatus.UNKNOWN; - } + media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; mediaRepository.save(media); } @@ -314,9 +306,9 @@ export class MediaRequest { media.requests.filter( (request) => request.status === MediaRequestStatus.PENDING ).length === 0 && - media.status === MediaStatus.PENDING + media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING ) { - media.status = MediaStatus.UNKNOWN; + media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; mediaRepository.save(media); } @@ -490,7 +482,7 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media.status = MediaStatus.UNKNOWN; + media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; await mediaRepository.save(media); logger.warn( 'Newly added movie request failed to add to Radarr, marking as unknown', @@ -700,7 +692,7 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media.status = MediaStatus.UNKNOWN; + media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; await mediaRepository.save(media); logger.warn( 'Newly added series request failed to add to Sonarr, marking as unknown', diff --git a/server/entity/User.ts b/server/entity/User.ts index 25b57f716..5e83dd068 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -29,6 +29,7 @@ import { getSettings } from '../lib/settings'; import logger from '../logger'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; +import { UserPushSubscription } from './UserPushSubscription'; import { UserSettings } from './UserSettings'; @Entity() @@ -105,6 +106,9 @@ export class User { }) public settings?: UserSettings; + @OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user) + public pushSubscriptions: UserPushSubscription[]; + @CreateDateColumn() public createdAt: Date; diff --git a/server/entity/UserPushSubscription.ts b/server/entity/UserPushSubscription.ts new file mode 100644 index 000000000..6389ea0b8 --- /dev/null +++ b/server/entity/UserPushSubscription.ts @@ -0,0 +1,27 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from './User'; + +@Entity() +export class UserPushSubscription { + @PrimaryGeneratedColumn() + public id: number; + + @ManyToOne(() => User, (user) => user.pushSubscriptions, { + eager: true, + onDelete: 'CASCADE', + }) + public user: User; + + @Column() + public endpoint: string; + + @Column() + public p256dh: string; + + @Column({ unique: true }) + public auth: string; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 023a1bde7..96227bf08 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -5,12 +5,15 @@ import { OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { - hasNotificationAgentEnabled, - NotificationAgentType, -} from '../lib/notifications/agenttypes'; +import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces'; +import { hasNotificationType, Notification } from '../lib/notifications'; +import { NotificationAgentKey } from '../lib/settings'; import { User } from './User'; +export const ALL_NOTIFICATIONS = Object.values(Notification) + .filter((v) => !isNaN(Number(v))) + .reduce((a, v) => a + Number(v), 0); + @Entity() export class UserSettings { constructor(init?: Partial) { @@ -24,15 +27,15 @@ export class UserSettings { @JoinColumn() public user: User; + @Column({ default: '' }) + public locale?: string; + @Column({ nullable: true }) public region?: string; @Column({ nullable: true }) public originalLanguage?: string; - @Column({ type: 'integer', default: NotificationAgentType.EMAIL }) - public notificationAgents = NotificationAgentType.EMAIL; - @Column({ nullable: true }) public pgpKey?: string; @@ -45,7 +48,67 @@ export class UserSettings { @Column({ nullable: true }) public telegramSendSilently?: boolean; - public hasNotificationAgentEnabled(agent: NotificationAgentType): boolean { - return !!hasNotificationAgentEnabled(agent, this.notificationAgents); + @Column({ + type: 'text', + nullable: true, + transformer: { + from: (value: string | null): Partial => { + const defaultTypes = { + email: ALL_NOTIFICATIONS, + discord: 0, + pushbullet: 0, + pushover: 0, + slack: 0, + telegram: 0, + webhook: 0, + webpush: ALL_NOTIFICATIONS, + }; + if (!value) { + return defaultTypes; + } + + const values = JSON.parse(value) as Partial; + + // Something with the migration to this field has caused some issue where + // the value pre-populates with just a raw "2"? Here we check if that's the case + // and return the default notification types if so + if (typeof values !== 'object') { + return defaultTypes; + } + + if (values.email == null) { + values.email = ALL_NOTIFICATIONS; + } + + if (values.webpush == null) { + values.webpush = ALL_NOTIFICATIONS; + } + + return values; + }, + to: (value: Partial): string | null => { + if (!value || typeof value !== 'object') { + return null; + } + + const allowedKeys = Object.values(NotificationAgentKey); + + // Remove any unknown notification agent keys before saving to db + (Object.keys(value) as (keyof NotificationAgentTypes)[]).forEach( + (key) => { + if (!allowedKeys.includes(key)) { + delete value[key]; + } + } + ); + + return JSON.stringify(value); + }, + }, + }) + public notificationTypes: Partial; + + public hasNotificationType(key: NotificationAgentKey, type: Notification) { + return hasNotificationType(type, this.notificationTypes[key] ?? 0); } } diff --git a/server/index.ts b/server/index.ts index 3cfd0dba3..e76d0e3db 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,30 +1,32 @@ -import express, { Request, Response, NextFunction } from 'express'; -import next from 'next'; -import path from 'path'; -import { createConnection, getRepository } from 'typeorm'; -import routes from './routes'; +import { getClientIp } from '@supercharge/request-ip'; import bodyParser from 'body-parser'; +import { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; import csurf from 'csurf'; +import express, { NextFunction, Request, Response } from 'express'; +import * as OpenApiValidator from 'express-openapi-validator'; import session, { Store } from 'express-session'; -import { TypeormStore } from 'connect-typeorm/out'; -import YAML from 'yamljs'; +import next from 'next'; +import path from 'path'; import swaggerUi from 'swagger-ui-express'; -import * as OpenApiValidator from 'express-openapi-validator'; +import { createConnection, getRepository } from 'typeorm'; +import YAML from 'yamljs'; import { Session } from './entity/Session'; -import { getSettings } from './lib/settings'; -import logger from './logger'; import { startJobs } from './job/schedule'; import notificationManager from './lib/notifications'; import DiscordAgent from './lib/notifications/agents/discord'; import EmailAgent from './lib/notifications/agents/email'; -import TelegramAgent from './lib/notifications/agents/telegram'; -import { getAppVersion } from './utils/appVersion'; -import SlackAgent from './lib/notifications/agents/slack'; +import LunaSeaAgent from './lib/notifications/agents/lunasea'; +import PushbulletAgent from './lib/notifications/agents/pushbullet'; import PushoverAgent from './lib/notifications/agents/pushover'; +import SlackAgent from './lib/notifications/agents/slack'; +import TelegramAgent from './lib/notifications/agents/telegram'; import WebhookAgent from './lib/notifications/agents/webhook'; -import { getClientIp } from '@supercharge/request-ip'; -import PushbulletAgent from './lib/notifications/agents/pushbullet'; +import WebPushAgent from './lib/notifications/agents/webpush'; +import { getSettings } from './lib/settings'; +import logger from './logger'; +import routes from './routes'; +import { getAppVersion } from './utils/appVersion'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -52,11 +54,13 @@ app notificationManager.registerAgents([ new DiscordAgent(), new EmailAgent(), + new LunaSeaAgent(), new PushbulletAgent(), new PushoverAgent(), new SlackAgent(), new TelegramAgent(), new WebhookAgent(), + new WebPushAgent(), ]); // Start Jobs diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 7c40c6db8..a55b71b3a 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -30,6 +30,9 @@ export interface PublicSettingsResponse { originalLanguage: string; partialRequestsEnabled: boolean; cacheImages: boolean; + vapidPublic: string; + enablePushRegistration: boolean; + locale: string; } export interface CacheItem { diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 006facf00..8fb6ae87d 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,5 +1,8 @@ +import { NotificationAgentKey } from '../../lib/settings'; + export interface UserSettingsGeneralResponse { username?: string; + locale?: string; region?: string; originalLanguage?: string; movieQuotaLimit?: number; @@ -12,8 +15,8 @@ export interface UserSettingsGeneralResponse { globalTvQuotaDays?: number; } +export type NotificationAgentTypes = Record; export interface UserSettingsNotificationsResponse { - notificationAgents: number; emailEnabled?: boolean; pgpKey?: string; discordEnabled?: boolean; @@ -22,4 +25,6 @@ export interface UserSettingsNotificationsResponse { telegramBotUsername?: string; telegramChatId?: string; telegramSendSilently?: boolean; + webPushEnabled?: boolean; + notificationTypes: Partial; } diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts index f9c0c7479..1274d6a8b 100644 --- a/server/lib/email/index.ts +++ b/server/lib/email/index.ts @@ -1,14 +1,20 @@ import Email from 'email-templates'; import nodemailer from 'nodemailer'; -import { NotificationAgentEmail } from '../settings'; +import { URL } from 'url'; +import { getSettings, NotificationAgentEmail } from '../settings'; import { openpgpEncrypt } from './openpgpEncrypt'; class PreparedEmail extends Email { public constructor(settings: NotificationAgentEmail, pgpKey?: string) { + const { applicationUrl } = getSettings().main; + const transport = nodemailer.createTransport({ + name: applicationUrl ? new URL(applicationUrl).hostname : undefined, host: settings.options.smtpHost, port: settings.options.smtpPort, secure: settings.options.secure, + ignoreTLS: settings.options.ignoreTls, + requireTLS: settings.options.requireTls, tls: settings.options.allowSelfSigned ? { rejectUnauthorized: false, @@ -22,6 +28,7 @@ class PreparedEmail extends Email { } : undefined, }); + if (pgpKey) { transport.use( 'stream', @@ -32,6 +39,7 @@ class PreparedEmail extends Email { }) ); } + super({ message: { from: { diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts index 146dc73e8..607173f79 100644 --- a/server/lib/email/openpgpEncrypt.ts +++ b/server/lib/email/openpgpEncrypt.ts @@ -1,6 +1,6 @@ +import crypto from 'crypto'; import * as openpgp from 'openpgp'; import { Transform, TransformCallback } from 'stream'; -import crypto from 'crypto'; interface EncryptorOptions { signingKey?: string; @@ -54,7 +54,8 @@ class PGPEncryptor extends Transform { privateKey = await openpgp.readKey({ armoredKey: this._signingKey, }); - await privateKey.decrypt(this._password); + + await openpgp.decryptKey({ privateKey, passphrase: this._password }); } const emailPartDelimiter = '\r\n\r\n'; @@ -128,11 +129,12 @@ class PGPEncryptor extends Transform { .join('\r\n'); const encryptedMessage = await openpgp.encrypt({ - message: openpgp.Message.fromText( - contentHeadersRaw + + message: await openpgp.createMessage({ + text: + contentHeadersRaw + emailPartDelimiter + - messageParts.join(emailPartDelimiter) - ), + messageParts.join(emailPartDelimiter), + }), publicKeys: validPublicKeys, privateKeys: privateKey, }); diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index c04b4948e..1b79f7e3a 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -4,8 +4,11 @@ import { hasNotificationType, Notification } from '..'; import { User } from '../../../entity/User'; import logger from '../../../logger'; import { Permission } from '../../permissions'; -import { getSettings, NotificationAgentDiscord } from '../../settings'; -import { NotificationAgentType } from '../agenttypes'; +import { + getSettings, + NotificationAgentDiscord, + NotificationAgentKey, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; enum EmbedColors { @@ -227,8 +230,9 @@ class DiscordAgent if (payload.notifyUser) { // Mention user who submitted the request if ( - payload.notifyUser.settings?.hasNotificationAgentEnabled( - NotificationAgentType.DISCORD + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.DISCORD, + type ) && payload.notifyUser.settings?.discordId ) { @@ -243,8 +247,9 @@ class DiscordAgent .filter( (user) => user.hasPermission(Permission.MANAGE_REQUESTS) && - user.settings?.hasNotificationAgentEnabled( - NotificationAgentType.DISCORD + user.settings?.hasNotificationType( + NotificationAgentKey.DISCORD, + type ) && user.settings?.discordId ) @@ -267,7 +272,7 @@ class DiscordAgent type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response.data, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 4d00eb6f2..d3f341862 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -7,8 +7,11 @@ import { User } from '../../../entity/User'; import logger from '../../../logger'; import PreparedEmail from '../../email'; import { Permission } from '../../permissions'; -import { getSettings, NotificationAgentEmail } from '../../settings'; -import { NotificationAgentType } from '../agenttypes'; +import { + getSettings, + NotificationAgentEmail, + NotificationAgentKey, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; class EmailAgent @@ -152,9 +155,13 @@ class EmailAgent // Send notification to the user who submitted the request if ( !payload.notifyUser.settings || - payload.notifyUser.settings.hasNotificationAgentEnabled( - NotificationAgentType.EMAIL - ) + // Check if user has email notifications enabled and fallback to true if undefined + // since email should default to true + (payload.notifyUser.settings.hasNotificationType( + NotificationAgentKey.EMAIL, + type + ) ?? + true) ) { logger.debug('Sending email notification', { label: 'Notifications', @@ -194,9 +201,13 @@ class EmailAgent (user) => user.hasPermission(Permission.MANAGE_REQUESTS) && (!user.settings || - user.settings.hasNotificationAgentEnabled( - NotificationAgentType.EMAIL - )) + // Check if user has email notifications enabled and fallback to true if undefined + // since email should default to true + (user.settings.hasNotificationType( + NotificationAgentKey.EMAIL, + type + ) ?? + true)) ) .map(async (user) => { logger.debug('Sending email notification', { diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts new file mode 100644 index 000000000..9fc332f6d --- /dev/null +++ b/server/lib/notifications/agents/lunasea.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; +import { hasNotificationType, Notification } from '..'; +import { MediaStatus } from '../../../constants/media'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentLunaSea } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +class LunaSeaAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentLunaSea { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.lunasea; + } + + private buildPayload(type: Notification, payload: NotificationPayload) { + return { + notification_type: Notification[type], + subject: payload.subject, + message: payload.message, + image: payload.image ?? null, + email: payload.notifyUser?.email, + username: payload.notifyUser?.username, + avatar: payload.notifyUser?.avatar, + media: payload.media + ? { + media_type: payload.media.mediaType, + tmdbId: payload.media.tmdbId, + imdbId: payload.media.imdbId, + tvdbId: payload.media.tvdbId, + status: MediaStatus[payload.media.status], + status4k: MediaStatus[payload.media.status4k], + } + : null, + extra: payload.extra ?? [], + request: payload.request + ? { + request_id: payload.request.id, + requestedBy_email: payload.request.requestedBy.email, + requestedBy_username: payload.request.requestedBy.displayName, + requestedBy_avatar: payload.request.requestedBy.avatar, + } + : null, + }; + } + + public shouldSend(type: Notification): boolean { + if ( + this.getSettings().enabled && + this.getSettings().options.webhookUrl && + hasNotificationType(type, this.getSettings().types) + ) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending LunaSea notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + }); + + try { + const { webhookUrl, profileName } = this.getSettings().options; + + if (!webhookUrl) { + return false; + } + + await axios.post(webhookUrl, this.buildPayload(type, payload), { + headers: { + Authorization: `Basic ${Buffer.from(`${profileName}:`).toString( + 'base64' + )}`, + }, + }); + + return true; + } catch (e) { + logger.error('Error sending LunaSea notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response.data, + }); + + return false; + } + } +} + +export default LunaSeaAgent; diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index c43e99711..ab4b811e4 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -170,7 +170,7 @@ class PushbulletAgent type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response.data, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index f9bff21c3..858da0c69 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -196,7 +196,7 @@ class PushoverAgent type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response.data, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index f9fe46c9d..7004fe4bf 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -254,7 +254,7 @@ class SlackAgent type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response.data, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 894a77262..1a22ddcec 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -2,8 +2,11 @@ import axios from 'axios'; import { hasNotificationType, Notification } from '..'; import { MediaType } from '../../../constants/media'; import logger from '../../../logger'; -import { getSettings, NotificationAgentTelegram } from '../../settings'; -import { NotificationAgentType } from '../agenttypes'; +import { + getSettings, + NotificationAgentKey, + NotificationAgentTelegram, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; interface TelegramMessagePayload { @@ -198,8 +201,9 @@ class TelegramAgent if ( payload.notifyUser && - payload.notifyUser.settings?.hasNotificationAgentEnabled( - NotificationAgentType.TELEGRAM + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.TELEGRAM, + type ) && payload.notifyUser.settings?.telegramChatId && payload.notifyUser.settings?.telegramChatId !== @@ -240,7 +244,7 @@ class TelegramAgent type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response.data, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 7630cf443..7d8cbd86f 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -154,7 +154,7 @@ class WebhookAgent type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response.data, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts new file mode 100644 index 000000000..fb3376701 --- /dev/null +++ b/server/lib/notifications/agents/webpush.ts @@ -0,0 +1,234 @@ +import { getRepository } from 'typeorm'; +import webpush from 'web-push'; +import { hasNotificationType, Notification } from '..'; +import { MediaType } from '../../../constants/media'; +import { User } from '../../../entity/User'; +import { UserPushSubscription } from '../../../entity/UserPushSubscription'; +import logger from '../../../logger'; +import { Permission } from '../../permissions'; +import { + getSettings, + NotificationAgentConfig, + NotificationAgentKey, +} from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +interface PushNotificationPayload { + notificationType: string; + mediaType?: 'movie' | 'tv'; + tmdbId?: number; + subject: string; + message?: string; + image?: string; + actionUrl?: string; + requestId?: number; +} + +class WebPushAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentConfig { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.webpush; + } + + private getNotificationPayload( + type: Notification, + payload: NotificationPayload + ): PushNotificationPayload { + switch (type) { + case Notification.TEST_NOTIFICATION: + return { + notificationType: Notification[type], + subject: payload.subject, + message: payload.message, + }; + case Notification.MEDIA_APPROVED: + return { + notificationType: Notification[type], + subject: payload.subject, + message: `Your ${ + payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series' + } request has been approved.`, + image: payload.image, + mediaType: payload.media?.mediaType, + tmdbId: payload.media?.tmdbId, + requestId: payload.request?.id, + actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }; + case Notification.MEDIA_AUTO_APPROVED: + return { + notificationType: Notification[type], + subject: payload.subject, + message: `Automatically approved a new ${ + payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series' + } request from ${payload.request?.requestedBy.displayName}.`, + image: payload.image, + mediaType: payload.media?.mediaType, + tmdbId: payload.media?.tmdbId, + requestId: payload.request?.id, + actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }; + case Notification.MEDIA_AVAILABLE: + return { + notificationType: Notification[type], + subject: payload.subject, + message: `Your ${ + payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series' + } request is now available!`, + image: payload.image, + mediaType: payload.media?.mediaType, + tmdbId: payload.media?.tmdbId, + requestId: payload.request?.id, + actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }; + case Notification.MEDIA_DECLINED: + return { + notificationType: Notification[type], + subject: payload.subject, + message: `Your ${ + payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series' + } request was declined.`, + image: payload.image, + mediaType: payload.media?.mediaType, + tmdbId: payload.media?.tmdbId, + requestId: payload.request?.id, + actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }; + case Notification.MEDIA_FAILED: + return { + notificationType: Notification[type], + subject: payload.subject, + message: `Failed to process ${ + payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series' + } request.`, + image: payload.image, + mediaType: payload.media?.mediaType, + tmdbId: payload.media?.tmdbId, + requestId: payload.request?.id, + actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }; + case Notification.MEDIA_PENDING: + return { + notificationType: Notification[type], + subject: payload.subject, + message: `Approval required for new ${ + payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series' + } request from ${payload.request?.requestedBy.displayName}.`, + image: payload.image, + mediaType: payload.media?.mediaType, + tmdbId: payload.media?.tmdbId, + requestId: payload.request?.id, + actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }; + } + } + + public shouldSend(type: Notification): boolean { + if ( + this.getSettings().enabled && + hasNotificationType(type, this.getSettings().types) + ) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending web push notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + }); + const userRepository = getRepository(User); + const userPushSubRepository = getRepository(UserPushSubscription); + const settings = getSettings(); + + let pushSubs: UserPushSubscription[] = []; + + const mainUser = await userRepository.findOne({ where: { id: 1 } }); + + if ( + payload.notifyUser && + // Check if user has webpush notifications enabled and fallback to true if undefined + // since web push should default to true + (payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.WEBPUSH, + type + ) ?? + true) + ) { + const notifySubs = await userPushSubRepository.find({ + where: { user: payload.notifyUser.id }, + }); + + pushSubs = notifySubs; + } else if (!payload.notifyUser) { + const users = await userRepository.find(); + + const manageUsers = users.filter( + (user) => + user.hasPermission(Permission.MANAGE_REQUESTS) && + // Check if user has webpush notifications enabled and fallback to true if undefined + // since web push should default to true + (user.settings?.hasNotificationType( + NotificationAgentKey.WEBPUSH, + type + ) ?? + true) + ); + + const allSubs = await userPushSubRepository + .createQueryBuilder('pushSub') + .where('pushSub.userId IN (:users)', { + users: manageUsers.map((user) => user.id), + }) + .getMany(); + + pushSubs = allSubs; + } + + if (mainUser && pushSubs.length > 0) { + webpush.setVapidDetails( + `mailto:${mainUser.email}`, + settings.vapidPublic, + settings.vapidPrivate + ); + + Promise.all( + pushSubs.map(async (sub) => { + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + auth: sub.auth, + p256dh: sub.p256dh, + }, + }, + Buffer.from( + JSON.stringify(this.getNotificationPayload(type, payload)), + 'utf-8' + ) + ); + } catch (e) { + // Failed to send notification so we need to remove the subscription + userPushSubRepository.remove(sub); + } + }) + ); + } + return true; + } +} + +export default WebPushAgent; diff --git a/server/lib/notifications/agenttypes.ts b/server/lib/notifications/agenttypes.ts deleted file mode 100644 index 9e0d79aa8..000000000 --- a/server/lib/notifications/agenttypes.ts +++ /dev/null @@ -1,16 +0,0 @@ -export enum NotificationAgentType { - NONE = 0, - EMAIL = 2, - DISCORD = 4, - TELEGRAM = 8, - PUSHOVER = 16, - PUSHBULLET = 32, - SLACK = 64, -} - -export const hasNotificationAgentEnabled = ( - agent: NotificationAgentType, - value: number -): boolean => { - return !!(value & agent); -}; diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 5006a0045..fbf36e6b8 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -17,6 +17,8 @@ export enum Permission { AUTO_APPROVE_4K = 32768, AUTO_APPROVE_4K_MOVIE = 65536, AUTO_APPROVE_4K_TV = 131072, + REQUEST_MOVIE = 262144, + REQUEST_TV = 524288, } export interface PermissionCheckOptions { diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index f35732099..4c4e6e7fc 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -72,6 +72,17 @@ class RadarrScanner } private async processRadarrMovie(radarrMovie: RadarrMovie): Promise { + if (!radarrMovie.monitored && !radarrMovie.downloaded) { + this.log( + 'Title is unmonitored and has not been downloaded. Skipping item.', + 'debug', + { + title: radarrMovie.title, + } + ); + return; + } + try { const server4k = this.enable4kMovie && this.currentServer.is4k; await this.processMovie(radarrMovie.tmdbId, { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 290d40406..a9e459b4a 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import { merge } from 'lodash'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; +import webpush from 'web-push'; import { Permission } from './permissions'; export interface Library { @@ -13,6 +14,7 @@ export interface Library { export interface Region { iso_3166_1: string; english_name: string; + name?: string; } export interface Language { @@ -81,10 +83,12 @@ export interface MainSettings { }; hideAvailable: boolean; localLogin: boolean; + newPlexLogin: boolean; region: string; originalLanguage: string; trustProxy: boolean; partialRequestsEnabled: boolean; + locale: string; } interface PublicSettings { @@ -101,6 +105,9 @@ interface FullPublicSettings extends PublicSettings { originalLanguage: string; partialRequestsEnabled: boolean; cacheImages: boolean; + vapidPublic: string; + enablePushRegistration: boolean; + locale: string; } export interface NotificationAgentConfig { @@ -128,6 +135,8 @@ export interface NotificationAgentEmail extends NotificationAgentConfig { smtpHost: string; smtpPort: number; secure: boolean; + ignoreTls: boolean; + requireTls: boolean; authUser?: string; authPass?: string; allowSelfSigned: boolean; @@ -137,6 +146,13 @@ export interface NotificationAgentEmail extends NotificationAgentConfig { }; } +export interface NotificationAgentLunaSea extends NotificationAgentConfig { + options: { + webhookUrl: string; + profileName: string; + }; +} + export interface NotificationAgentTelegram extends NotificationAgentConfig { options: { botUsername?: string; @@ -168,14 +184,27 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig { }; } +export enum NotificationAgentKey { + DISCORD = 'discord', + EMAIL = 'email', + PUSHBULLET = 'pushbullet', + PUSHOVER = 'pushover', + SLACK = 'slack', + TELEGRAM = 'telegram', + WEBHOOK = 'webhook', + WEBPUSH = 'webpush', +} + interface NotificationAgents { discord: NotificationAgentDiscord; email: NotificationAgentEmail; + lunasea: NotificationAgentLunaSea; pushbullet: NotificationAgentPushbullet; pushover: NotificationAgentPushover; slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; webhook: NotificationAgentWebhook; + webpush: NotificationAgentConfig; } interface NotificationSettings { @@ -184,6 +213,8 @@ interface NotificationSettings { interface AllSettings { clientId: string; + vapidPublic: string; + vapidPrivate: string; main: MainSettings; plex: PlexSettings; radarr: RadarrSettings[]; @@ -202,6 +233,8 @@ class Settings { constructor(initialSettings?: AllSettings) { this.data = { clientId: uuidv4(), + vapidPrivate: '', + vapidPublic: '', main: { apiKey: '', applicationTitle: 'Overseerr', @@ -215,14 +248,16 @@ class Settings { }, hideAvailable: false, localLogin: true, + newPlexLogin: true, region: '', originalLanguage: '', trustProxy: false, partialRequestsEnabled: true, + locale: 'en', }, plex: { name: '', - ip: '127.0.0.1', + ip: '', port: 32400, useSsl: false, libraries: [], @@ -239,9 +274,11 @@ class Settings { types: 0, options: { emailFrom: '', - smtpHost: '127.0.0.1', + smtpHost: '', smtpPort: 587, secure: false, + ignoreTls: false, + requireTls: false, allowSelfSigned: false, senderName: 'Overseerr', }, @@ -255,6 +292,14 @@ class Settings { webhookUrl: '', }, }, + lunasea: { + enabled: false, + types: 0, + options: { + webhookUrl: '', + profileName: '', + }, + }, slack: { enabled: false, types: 0, @@ -298,6 +343,11 @@ class Settings { 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i', }, }, + webpush: { + enabled: false, + types: 0, + options: {}, + }, }, }, }; @@ -366,6 +416,9 @@ class Settings { originalLanguage: this.data.main.originalLanguage, partialRequestsEnabled: this.data.main.partialRequestsEnabled, cacheImages: this.data.main.cacheImages, + vapidPublic: this.vapidPublic, + enablePushRegistration: this.data.notifications.agents.webpush.enabled, + locale: this.data.main.locale, }; } @@ -386,6 +439,18 @@ class Settings { return this.data.clientId; } + get vapidPublic(): string { + this.generateVapidKeys(); + + return this.data.vapidPublic; + } + + get vapidPrivate(): string { + this.generateVapidKeys(); + + return this.data.vapidPrivate; + } + public regenerateApiKey(): MainSettings { this.main.apiKey = this.generateApiKey(); this.save(); @@ -396,6 +461,15 @@ class Settings { return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64'); } + private generateVapidKeys(force = false): void { + if (!this.data.vapidPublic || !this.data.vapidPrivate || force) { + const vapidKeys = webpush.generateVAPIDKeys(); + this.data.vapidPrivate = vapidKeys.privateKey; + this.data.vapidPublic = vapidKeys.publicKey; + this.save(); + } + } + /** * Settings Load * diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 6d36bb2f9..f8f7b9ade 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -5,6 +5,7 @@ import { getSettings } from '../lib/settings'; export const checkUser: Middleware = async (req, _res, next) => { const settings = getSettings(); + if (req.header('X-API-Key') === settings.main.apiKey) { const userRepository = getRepository(User); @@ -28,8 +29,12 @@ export const checkUser: Middleware = async (req, _res, next) => { if (user) { req.user = user; + req.locale = user.settings?.locale + ? user.settings?.locale + : settings.main.locale; } } + next(); }; diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts new file mode 100644 index 000000000..90ea0d3f9 --- /dev/null +++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserPushSubscriptions1618912653565 + implements MigrationInterface { + name = 'CreateUserPushSubscriptions1618912653565'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + } +} diff --git a/server/migration/1619239659754-AddUserSettingsLocale.ts b/server/migration/1619239659754-AddUserSettingsLocale.ts new file mode 100644 index 000000000..9842bca71 --- /dev/null +++ b/server/migration/1619239659754-AddUserSettingsLocale.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserSettingsLocale1619239659754 implements MigrationInterface { + name = 'AddUserSettingsLocale1619239659754'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts new file mode 100644 index 000000000..67d770722 --- /dev/null +++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserSettingsNotificationTypes1619339817343 + implements MigrationInterface { + name = 'AddUserSettingsNotificationTypes1619339817343'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d5bf42bce..ca94e2a8c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,12 +1,12 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; -import { User } from '../entity/User'; import PlexTvAPI from '../api/plextv'; -import { isAuthenticated } from '../middleware/auth'; +import { UserType } from '../constants/user'; +import { User } from '../entity/User'; import { Permission } from '../lib/permissions'; -import logger from '../logger'; import { getSettings } from '../lib/settings'; -import { UserType } from '../constants/user'; +import logger from '../logger'; +import { isAuthenticated } from '../middleware/auth'; const authRoutes = Router(); @@ -79,6 +79,24 @@ authRoutes.post('/plex', async (req, res, next) => { // Double check that we didn't create the first admin user before running this if (!user) { + if (!settings.main.newPlexLogin) { + logger.info( + 'Failed sign-in attempt from user who has not been imported to Overseerr.', + { + label: 'Auth', + account: { + ...account, + authentication_token: '__REDACTED__', + authToken: '__REDACTED__', + }, + } + ); + return next({ + status: 403, + message: 'Access denied.', + }); + } + // If we get to this point, the user does not already exist so we need to create the // user _assuming_ they have access to the Plex server const mainUser = await userRepository.findOneOrFail({ @@ -112,7 +130,7 @@ authRoutes.post('/plex', async (req, res, next) => { ); return next({ status: 403, - message: 'You do not have access to this Plex server.', + message: 'Access denied.', }); } } @@ -128,7 +146,7 @@ authRoutes.post('/plex', async (req, res, next) => { logger.error(e.message, { label: 'Auth' }); return next({ status: 500, - message: 'Something went wrong. Is your auth token valid?', + message: 'Something went wrong.', }); } }); diff --git a/server/routes/collection.ts b/server/routes/collection.ts index 75f1a455f..8ffbb51c9 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -11,7 +11,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { try { const collection = await tmdb.getCollection({ collectionId: Number(req.params.id), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 3e690c8e5..dd3a9fa66 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,16 +1,16 @@ import { Router } from 'express'; +import { sortBy } from 'lodash'; import TheMovieDb from '../api/themoviedb'; -import { mapMovieResult, mapTvResult, mapPersonResult } from '../models/Search'; -import Media from '../entity/Media'; -import { isMovie, isPerson } from '../utils/typeHelpers'; import { MediaType } from '../constants/media'; -import { getSettings } from '../lib/settings'; +import Media from '../entity/Media'; import { User } from '../entity/User'; +import { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; +import { getSettings } from '../lib/settings'; +import logger from '../logger'; import { mapProductionCompany } from '../models/Movie'; +import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search'; import { mapNetwork } from '../models/Tv'; -import logger from '../logger'; -import { sortBy } from 'lodash'; -import { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; +import { isMovie, isPerson } from '../utils/typeHelpers'; const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -42,7 +42,7 @@ discoverRoutes.get('/movies', async (req, res) => { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), genre: req.query.genre ? Number(req.query.genre) : undefined, studio: req.query.studio ? Number(req.query.studio) : undefined, }); @@ -83,7 +83,7 @@ discoverRoutes.get<{ language: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), originalLanguage: req.params.language, }); @@ -115,7 +115,7 @@ discoverRoutes.get<{ genreId: string }>( const tmdb = createTmdbWithRegionLanaguage(req.user); const genres = await tmdb.getMovieGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const genre = genres.find( @@ -128,7 +128,7 @@ discoverRoutes.get<{ genreId: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), genre: Number(req.params.genreId), }); @@ -164,7 +164,7 @@ discoverRoutes.get<{ studioId: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), studio: Number(req.params.studioId), }); @@ -204,7 +204,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), primaryReleaseDateGte: date, }); @@ -232,7 +232,7 @@ discoverRoutes.get('/tv', async (req, res) => { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), genre: req.query.genre ? Number(req.query.genre) : undefined, network: req.query.network ? Number(req.query.network) : undefined, }); @@ -273,7 +273,7 @@ discoverRoutes.get<{ language: string }>( const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), originalLanguage: req.params.language, }); @@ -304,7 +304,7 @@ discoverRoutes.get<{ genreId: string }>( const tmdb = createTmdbWithRegionLanaguage(req.user); const genres = await tmdb.getTvGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const genre = genres.find( @@ -317,7 +317,7 @@ discoverRoutes.get<{ genreId: string }>( const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), genre: Number(req.params.genreId), }); @@ -352,7 +352,7 @@ discoverRoutes.get<{ networkId: string }>( const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), network: Number(req.params.networkId), }); @@ -392,7 +392,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), firstAirDateGte: date, }); @@ -420,7 +420,7 @@ discoverRoutes.get('/trending', async (req, res) => { const data = await tmdb.getAllTrending({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -461,7 +461,7 @@ discoverRoutes.get<{ keywordId: string }>( const data = await tmdb.getMoviesByKeyword({ keywordId: Number(req.params.keywordId), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -494,7 +494,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( const mappedGenres: GenreSliderItem[] = []; const genres = await tmdb.getMovieGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); await Promise.all( @@ -535,7 +535,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( const mappedGenres: GenreSliderItem[] = []; const genres = await tmdb.getTvGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); await Promise.all( diff --git a/server/routes/index.ts b/server/routes/index.ts index 72b98c8f7..72e396c57 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -81,10 +81,16 @@ router.get('/status/appdata', (_req, res) => { }); router.use('/user', isAuthenticated(), user); -router.get('/settings/public', async (_req, res) => { +router.get('/settings/public', async (req, res) => { const settings = getSettings(); - return res.status(200).json(settings.fullPublicSettings); + if (!req.user?.settings?.notificationTypes.webpush) { + return res + .status(200) + .json({ ...settings.fullPublicSettings, enablePushRegistration: false }); + } else { + return res.status(200).json(settings.fullPublicSettings); + } }); router.use( '/settings', @@ -138,7 +144,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res) => { const tmdb = new TheMovieDb(); const genres = await tmdb.getMovieGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); return res.status(200).json(genres); @@ -148,7 +154,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res) => { const tmdb = new TheMovieDb(); const genres = await tmdb.getTvGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); return res.status(200).json(genres); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index cadaf5a7e..d871652a4 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,11 +1,11 @@ import { Router } from 'express'; +import RottenTomatoes from '../api/rottentomatoes'; import TheMovieDb from '../api/themoviedb'; -import { mapMovieDetails } from '../models/Movie'; -import { mapMovieResult } from '../models/Search'; +import { MediaType } from '../constants/media'; import Media from '../entity/Media'; -import RottenTomatoes from '../api/rottentomatoes'; import logger from '../logger'; -import { MediaType } from '../constants/media'; +import { mapMovieDetails } from '../models/Movie'; +import { mapMovieResult } from '../models/Search'; const movieRoutes = Router(); @@ -15,7 +15,7 @@ movieRoutes.get('/:id', async (req, res, next) => { try { const tmdbMovie = await tmdb.getMovie({ movieId: Number(req.params.id), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); @@ -36,7 +36,7 @@ movieRoutes.get('/:id/recommendations', async (req, res) => { const results = await tmdb.getMovieRecommendations({ movieId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -64,7 +64,7 @@ movieRoutes.get('/:id/similar', async (req, res) => { const results = await tmdb.getMovieSimilar({ movieId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/person.ts b/server/routes/person.ts index 7b8d90c4f..e18e55c84 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => { try { const person = await tmdb.getPerson({ personId: Number(req.params.id), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); return res.status(200).json(mapPersonDetails(person)); } catch (e) { @@ -30,7 +30,7 @@ personRoutes.get('/:id/combined_credits', async (req, res) => { const combinedCredits = await tmdb.getPersonCombinedCredits({ personId: Number(req.params.id), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const castMedia = await Media.getRelatedMedia( diff --git a/server/routes/request.ts b/server/routes/request.ts index df0b55453..96f476940 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -139,287 +139,289 @@ requestRoutes.get('/', async (req, res, next) => { } }); -requestRoutes.post( - '/', - isAuthenticated(Permission.REQUEST), - async (req, res, next) => { - const tmdb = new TheMovieDb(); - const mediaRepository = getRepository(Media); - const requestRepository = getRepository(MediaRequest); - const userRepository = getRepository(User); +requestRoutes.post('/', async (req, res, next) => { + const tmdb = new TheMovieDb(); + const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + const userRepository = getRepository(User); - try { - let requestUser = req.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 }, - }); - } + 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 }, + }); + } - if (!requestUser) { - return next({ - status: 500, - message: 'User missing from request context.', - }); - } + if (!requestUser) { + return next({ + status: 500, + message: 'User missing from request context.', + }); + } - if (req.body.is4k) { - if ( - req.body.mediaType === MediaType.MOVIE && - !req.user?.hasPermission( - [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: 'You do not have permission to make 4K movie requests.', - }); - } else if ( - req.body.mediaType === MediaType.TV && - !req.user?.hasPermission( - [Permission.REQUEST_4K, Permission.REQUEST_4K_TV], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: 'You do not have permission to make 4K series requests.', - }); + if ( + req.body.mediaType === MediaType.MOVIE && + !req.user?.hasPermission( + req.body.is4k + ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE] + : [Permission.REQUEST, Permission.REQUEST_MOVIE], + { + type: 'or', } - } + ) + ) { + return next({ + status: 403, + message: `You do not have permission to make ${ + req.body.is4k ? '4K ' : '' + }movie requests.`, + }); + } else if ( + req.body.mediaType === MediaType.TV && + !req.user?.hasPermission( + req.body.is4k + ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV] + : [Permission.REQUEST, Permission.REQUEST_TV], + { + type: 'or', + } + ) + ) { + return next({ + status: 403, + message: `You do not have permission to make ${ + req.body.is4k ? '4K ' : '' + }series requests.`, + }); + } - const quotas = await requestUser.getQuota(); + const quotas = await requestUser.getQuota(); - if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) { - return next({ - status: 403, - message: 'Movie Quota Exceeded', - }); - } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) { - return next({ - status: 403, - message: 'Series Quota Exceeded', - }); + if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) { + return next({ + status: 403, + message: 'Movie Quota Exceeded', + }); + } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) { + return next({ + status: 403, + message: 'Series Quota Exceeded', + }); + } + + const tmdbMedia = + req.body.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: req.body.mediaId }) + : await tmdb.getTvShow({ tvId: req.body.mediaId }); + + let media = await mediaRepository.findOne({ + where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType }, + relations: ['requests'], + }); + + if (!media) { + media = new Media({ + tmdbId: tmdbMedia.id, + tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id, + status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + mediaType: req.body.mediaType, + }); + } else { + if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { + media.status = MediaStatus.PENDING; } - const tmdbMedia = - req.body.mediaType === MediaType.MOVIE - ? await tmdb.getMovie({ movieId: req.body.mediaId }) - : await tmdb.getTvShow({ tvId: req.body.mediaId }); + if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { + media.status4k = MediaStatus.PENDING; + } + } - let media = await mediaRepository.findOne({ - where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType }, - relations: ['requests'], + if (req.body.mediaType === MediaType.MOVIE) { + const existing = await requestRepository.findOne({ + where: { + media: { + tmdbId: tmdbMedia.id, + }, + requestedBy: req.user, + is4k: req.body.is4k, + }, }); - if (!media) { - media = new Media({ + if (existing) { + logger.warn('Duplicate request for media blocked', { tmdbId: tmdbMedia.id, - tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id, - status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: req.body.mediaType, }); - } else { - if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { - media.status = MediaStatus.PENDING; - } - - if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { - media.status4k = MediaStatus.PENDING; - } - } - - if (req.body.mediaType === MediaType.MOVIE) { - const existing = await requestRepository.findOne({ - where: { - media: { - tmdbId: tmdbMedia.id, - }, - requestedBy: req.user, - is4k: req.body.is4k, - }, + return next({ + status: 409, + message: 'Request for this media already exists.', }); + } - if (existing) { - logger.warn('Duplicate request for media blocked', { - tmdbId: tmdbMedia.id, - mediaType: req.body.mediaType, - }); - return next({ - status: 409, - message: 'Request for this media already exists.', - }); - } + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.MOVIE, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: req.user?.hasPermission( + [ + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + req.body.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: req.user?.hasPermission( + [ + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + req.body.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? req.user + : undefined, + is4k: req.body.is4k, + serverId: req.body.serverId, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, + tags: req.body.tags, + }); - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.MOVIE, - media, - requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } + await requestRepository.save(request); + return res.status(201).json(request); + } else if (req.body.mediaType === MediaType.TV) { + const requestedSeasons = req.body.seasons as number[]; + let existingSeasons: number[] = []; + + // We need to check existing requests on this title to make sure we don't double up on seasons that were + // already requested. In the case they were, we just throw out any duplicates but still approve the request. + // (Unless there are no seasons, in which case we abort) + if (media.requests) { + existingSeasons = media.requests + .filter( + (request) => + request.is4k === req.body.is4k && + request.status !== MediaRequestStatus.DECLINED ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - tags: req.body.tags, - }); - - await requestRepository.save(request); - return res.status(201).json(request); - } else if (req.body.mediaType === MediaType.TV) { - const requestedSeasons = req.body.seasons as number[]; - let existingSeasons: number[] = []; - - // We need to check existing requests on this title to make sure we don't double up on seasons that were - // already requested. In the case they were, we just throw out any duplicates but still approve the request. - // (Unless there are no seasons, in which case we abort) - if (media.requests) { - existingSeasons = media.requests - .filter( - (request) => - request.is4k === req.body.is4k && - request.status !== MediaRequestStatus.DECLINED - ) - .reduce((seasons, request) => { - const combinedSeasons = request.seasons.map( - (season) => season.seasonNumber - ); - - return [...seasons, ...combinedSeasons]; - }, [] as number[]); - } + .reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); - const finalSeasons = requestedSeasons.filter( - (rs) => !existingSeasons.includes(rs) - ); + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + } - if (finalSeasons.length === 0) { - return next({ - status: 202, - message: 'No seasons available to request', - }); - } + const finalSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) + ); - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.TV, - media, - requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - languageProfileId: req.body.languageProfileId, - tags: req.body.tags, - seasons: finalSeasons.map( - (sn) => - new SeasonRequest({ - seasonNumber: sn, - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - }) - ), + if (finalSeasons.length === 0) { + return next({ + status: 202, + message: 'No seasons available to request', }); - - await requestRepository.save(request); - return res.status(201).json(request); } - next({ status: 500, message: 'Invalid media type' }); - } catch (e) { - next({ status: 500, message: e.message }); + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.TV, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: req.user?.hasPermission( + [ + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + req.body.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: req.user?.hasPermission( + [ + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + req.body.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? req.user + : undefined, + is4k: req.body.is4k, + serverId: req.body.serverId, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, + languageProfileId: req.body.languageProfileId, + tags: req.body.tags, + seasons: finalSeasons.map( + (sn) => + new SeasonRequest({ + seasonNumber: sn, + status: req.user?.hasPermission( + [ + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + req.body.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + }) + ), + }); + + await requestRepository.save(request); + return res.status(201).json(request); } + + next({ status: 500, message: 'Invalid media type' }); + } catch (e) { + next({ status: 500, message: e.message }); } -); +}); requestRoutes.get('/count', async (_req, res, next) => { const requestRepository = getRepository(MediaRequest); diff --git a/server/routes/search.ts b/server/routes/search.ts index 622e54693..c843e78c3 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; -import { mapSearchResults } from '../models/Search'; import Media from '../entity/Media'; +import { mapSearchResults } from '../models/Search'; const searchRoutes = Router(); @@ -11,7 +11,7 @@ searchRoutes.get('/', async (req, res) => { const results = await tmdb.searchMulti({ query: req.query.query as string, page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/service.ts b/server/routes/service.ts index 51bbc4e31..862ab3748 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -191,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>( try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.tmdbId), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const response = await sonarr.getSeriesByTitle(tv.name); diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 739b3981d..bb21c7b60 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -2,11 +2,13 @@ import { Router } from 'express'; import { Notification } from '../../lib/notifications'; import DiscordAgent from '../../lib/notifications/agents/discord'; import EmailAgent from '../../lib/notifications/agents/email'; +import LunaSeaAgent from '../../lib/notifications/agents/lunasea'; import PushbulletAgent from '../../lib/notifications/agents/pushbullet'; import PushoverAgent from '../../lib/notifications/agents/pushover'; import SlackAgent from '../../lib/notifications/agents/slack'; import TelegramAgent from '../../lib/notifications/agents/telegram'; import WebhookAgent from '../../lib/notifications/agents/webhook'; +import WebPushAgent from '../../lib/notifications/agents/webpush'; import { getSettings } from '../../lib/settings'; const notificationRoutes = Router(); @@ -26,23 +28,30 @@ notificationRoutes.post('/discord', (req, res) => { res.status(200).json(settings.notifications.agents.discord); }); -notificationRoutes.post('/discord/test', (req, res, next) => { +notificationRoutes.post('/discord/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } const discordAgent = new DiscordAgent(req.body); - discordAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); + if ( + await discordAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Discord notification.', + }); + } }); notificationRoutes.get('/slack', (_req, res) => { @@ -60,23 +69,30 @@ notificationRoutes.post('/slack', (req, res) => { res.status(200).json(settings.notifications.agents.slack); }); -notificationRoutes.post('/slack/test', (req, res, next) => { +notificationRoutes.post('/slack/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } const slackAgent = new SlackAgent(req.body); - slackAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); + if ( + await slackAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Slack notification.', + }); + } }); notificationRoutes.get('/telegram', (_req, res) => { @@ -94,23 +110,30 @@ notificationRoutes.post('/telegram', (req, res) => { res.status(200).json(settings.notifications.agents.telegram); }); -notificationRoutes.post('/telegram/test', (req, res, next) => { +notificationRoutes.post('/telegram/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } const telegramAgent = new TelegramAgent(req.body); - telegramAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); + if ( + await telegramAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Telegram notification.', + }); + } }); notificationRoutes.get('/pushbullet', (_req, res) => { @@ -128,23 +151,30 @@ notificationRoutes.post('/pushbullet', (req, res) => { res.status(200).json(settings.notifications.agents.pushbullet); }); -notificationRoutes.post('/pushbullet/test', (req, res, next) => { +notificationRoutes.post('/pushbullet/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } const pushbulletAgent = new PushbulletAgent(req.body); - pushbulletAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); + if ( + await pushbulletAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Pushbullet notification.', + }); + } }); notificationRoutes.get('/pushover', (_req, res) => { @@ -162,23 +192,30 @@ notificationRoutes.post('/pushover', (req, res) => { res.status(200).json(settings.notifications.agents.pushover); }); -notificationRoutes.post('/pushover/test', (req, res, next) => { +notificationRoutes.post('/pushover/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } const pushoverAgent = new PushoverAgent(req.body); - pushoverAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); + if ( + await pushoverAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Pushover notification.', + }); + } }); notificationRoutes.get('/email', (_req, res) => { @@ -196,23 +233,71 @@ notificationRoutes.post('/email', (req, res) => { res.status(200).json(settings.notifications.agents.email); }); -notificationRoutes.post('/email/test', (req, res, next) => { +notificationRoutes.post('/email/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } const emailAgent = new EmailAgent(req.body); - emailAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); + if ( + await emailAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send email notification.', + }); + } +}); + +notificationRoutes.get('/webpush', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.webpush); +}); + +notificationRoutes.post('/webpush', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.webpush = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.webpush); +}); + +notificationRoutes.post('/webpush/test', async (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const webpushAgent = new WebPushAgent(req.body); + if ( + await webpushAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send web push notification.', + }); + } }); notificationRoutes.get('/webhook', (_req, res) => { @@ -260,11 +345,11 @@ notificationRoutes.post('/webhook', (req, res, next) => { } }); -notificationRoutes.post('/webhook/test', (req, res, next) => { +notificationRoutes.post('/webhook/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } @@ -284,16 +369,64 @@ notificationRoutes.post('/webhook/test', (req, res, next) => { }; const webhookAgent = new WebhookAgent(testBody); - webhookAgent.send(Notification.TEST_NOTIFICATION, { + if ( + await webhookAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send webhook notification.', + }); + } + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +notificationRoutes.get('/lunasea', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.lunasea); +}); + +notificationRoutes.post('/lunasea', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.lunasea = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.lunasea); +}); + +notificationRoutes.post('/lunasea/test', async (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const lunaseaAgent = new LunaSeaAgent(req.body); + if ( + await lunaseaAgent.send(Notification.TEST_NOTIFICATION, { notifyUser: req.user, subject: 'Test Notification', message: 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - + }) + ) { return res.status(204).send(); - } catch (e) { - next({ status: 500, message: e.message }); + } else { + return next({ + status: 500, + message: 'Failed to send web push notification.', + }); } }); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 1ddf1f80c..043e610f7 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,11 +1,11 @@ import { Router } from 'express'; +import RottenTomatoes from '../api/rottentomatoes'; import TheMovieDb from '../api/themoviedb'; -import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv'; -import { mapTvResult } from '../models/Search'; +import { MediaType } from '../constants/media'; import Media from '../entity/Media'; -import RottenTomatoes from '../api/rottentomatoes'; import logger from '../logger'; -import { MediaType } from '../constants/media'; +import { mapTvResult } from '../models/Search'; +import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv'; const tvRoutes = Router(); @@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => { try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.id), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getMedia(tv.id, MediaType.TV); @@ -35,7 +35,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => { const season = await tmdb.getTvSeason({ tvId: Number(req.params.id), seasonNumber: Number(req.params.seasonNumber), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); return res.status(200).json(mapSeasonWithEpisodes(season)); @@ -47,7 +47,7 @@ tvRoutes.get('/:id/recommendations', async (req, res) => { const results = await tmdb.getTvRecommendations({ tvId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -75,7 +75,7 @@ tvRoutes.get('/:id/similar', async (req, res) => { const results = await tmdb.getTvSimilar({ tvId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index a0dab71c8..60d5c33e4 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -5,6 +5,7 @@ import PlexTvAPI from '../../api/plextv'; import { UserType } from '../../constants/user'; import { MediaRequest } from '../../entity/MediaRequest'; import { User } from '../../entity/User'; +import { UserPushSubscription } from '../../entity/UserPushSubscription'; import { QuotaResponse, UserRequestsResponse, @@ -127,6 +128,48 @@ router.post( } ); +router.post< + never, + unknown, + { + endpoint: string; + p256dh: string; + auth: string; + } +>('/registerPushSubscription', async (req, res, next) => { + try { + const userPushSubRepository = getRepository(UserPushSubscription); + + const existingSubs = await userPushSubRepository.find({ + where: { auth: req.body.auth }, + }); + + if (existingSubs.length > 0) { + logger.debug( + 'User push subscription already exists. Skipping registration.', + { label: 'API' } + ); + return res.status(204).send(); + } + + const userPushSubscription = new UserPushSubscription({ + auth: req.body.auth, + endpoint: req.body.endpoint, + p256dh: req.body.p256dh, + user: req.user, + }); + + userPushSubRepository.save(userPushSubscription); + + return res.status(204).send(); + } catch (e) { + logger.error('Failed to register user push subscription', { + label: 'API', + }); + next({ status: 500, message: 'Failed to register subscription.' }); + } +}); + router.get<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index f85ef1797..f37b8c86a 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -7,7 +7,6 @@ import { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, } from '../../interfaces/api/userSettingsInterfaces'; -import { NotificationAgentType } from '../../lib/notifications/agenttypes'; import { Permission } from '../../lib/permissions'; import { getSettings } from '../../lib/settings'; import logger from '../../logger'; @@ -52,6 +51,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( return res.status(200).json({ username: user.username, + locale: user.settings?.locale, region: user.settings?.region, originalLanguage: user.settings?.originalLanguage, movieQuotaLimit: user.movieQuotaLimit, @@ -109,17 +109,24 @@ userSettingsRoutes.post< if (!user.settings) { user.settings = new UserSettings({ user: req.user, + locale: req.body.locale, region: req.body.region, originalLanguage: req.body.originalLanguage, }); } else { + user.settings.locale = req.body.locale; user.settings.region = req.body.region; user.settings.originalLanguage = req.body.originalLanguage; } await userRepository.save(user); - return res.status(200).json({ username: user.username }); + return res.status(200).json({ + username: user.username, + region: user.settings.region, + locale: user.settings.locale, + originalLanguage: user.settings.originalLanguage, + }); } catch (e) { next({ status: 500, message: e.message }); } @@ -243,8 +250,6 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( } return res.status(200).json({ - notificationAgents: - user.settings?.notificationAgents ?? NotificationAgentType.EMAIL, emailEnabled: settings?.notifications.agents.email.enabled, pgpKey: user.settings?.pgpKey, discordEnabled: settings?.notifications.agents.discord.enabled, @@ -254,6 +259,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( settings?.notifications.agents.telegram.options.botUsername, telegramChatId: user.settings?.telegramChatId, telegramSendSilently: user?.settings?.telegramSendSilently, + webPushEnabled: settings?.notifications.agents.webpush.enabled, + notificationTypes: user.settings?.notificationTypes ?? {}, }); } catch (e) { next({ status: 500, message: e.message }); @@ -287,30 +294,32 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( if (!user.settings) { user.settings = new UserSettings({ user: req.user, - notificationAgents: - req.body.notificationAgents ?? NotificationAgentType.EMAIL, pgpKey: req.body.pgpKey, discordId: req.body.discordId, telegramChatId: req.body.telegramChatId, telegramSendSilently: req.body.telegramSendSilently, + notificationTypes: req.body.notificationTypes, }); } else { - user.settings.notificationAgents = - req.body.notificationAgents ?? NotificationAgentType.EMAIL; user.settings.pgpKey = req.body.pgpKey; user.settings.discordId = req.body.discordId; user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramSendSilently = req.body.telegramSendSilently; + user.settings.notificationTypes = Object.assign( + {}, + user.settings.notificationTypes, + req.body.notificationTypes + ); } userRepository.save(user); return res.status(200).json({ - notificationAgents: user.settings?.notificationAgents, pgpKey: user.settings?.pgpKey, discordId: user.settings?.discordId, telegramChatId: user.settings?.telegramChatId, telegramSendSilently: user?.settings?.telegramSendSilently, + notificationTypes: user.settings.notificationTypes, }); } catch (e) { next({ status: 500, message: e.message }); diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 90a880069..ee7fd9724 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -6,6 +6,7 @@ declare global { namespace Express { export interface Request { user?: User; + locale?: string; } } diff --git a/src/assets/extlogos/discord.svg b/src/assets/extlogos/discord.svg index bce41d990..736d9ddd2 100644 --- a/src/assets/extlogos/discord.svg +++ b/src/assets/extlogos/discord.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/extlogos/lunasea.svg b/src/assets/extlogos/lunasea.svg new file mode 100644 index 000000000..359ca8161 --- /dev/null +++ b/src/assets/extlogos/lunasea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/extlogos/pushbullet.svg b/src/assets/extlogos/pushbullet.svg index e6101705c..bd97ab860 100644 --- a/src/assets/extlogos/pushbullet.svg +++ b/src/assets/extlogos/pushbullet.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/extlogos/pushover.svg b/src/assets/extlogos/pushover.svg index 7225c8059..7b2413f30 100644 --- a/src/assets/extlogos/pushover.svg +++ b/src/assets/extlogos/pushover.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/extlogos/slack.svg b/src/assets/extlogos/slack.svg index f292c13cd..5c0db3a2a 100644 --- a/src/assets/extlogos/slack.svg +++ b/src/assets/extlogos/slack.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/extlogos/telegram.svg b/src/assets/extlogos/telegram.svg index f7cc49334..ba9984de4 100644 --- a/src/assets/extlogos/telegram.svg +++ b/src/assets/extlogos/telegram.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/rt_aud_fresh.svg b/src/assets/rt_aud_fresh.svg index 7143281bd..f9fa29044 100644 --- a/src/assets/rt_aud_fresh.svg +++ b/src/assets/rt_aud_fresh.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/rt_aud_rotten.svg b/src/assets/rt_aud_rotten.svg index c97f1f65a..cd84ac5b0 100644 --- a/src/assets/rt_aud_rotten.svg +++ b/src/assets/rt_aud_rotten.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/src/assets/rt_fresh.svg b/src/assets/rt_fresh.svg index 89c3e610c..ed6f44d73 100644 --- a/src/assets/rt_fresh.svg +++ b/src/assets/rt_fresh.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/rt_rotten.svg b/src/assets/rt_rotten.svg index e9c99d22b..60ba169e0 100644 --- a/src/assets/rt_rotten.svg +++ b/src/assets/rt_rotten.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/services/imdb.svg b/src/assets/services/imdb.svg index 59602f7e1..ffbad298f 100644 --- a/src/assets/services/imdb.svg +++ b/src/assets/services/imdb.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/services/plex.svg b/src/assets/services/plex.svg index e04ac484e..dd07bd8ae 100644 --- a/src/assets/services/plex.svg +++ b/src/assets/services/plex.svg @@ -1 +1 @@ -plex-logo \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/services/radarr.svg b/src/assets/services/radarr.svg new file mode 100644 index 000000000..1a373693d --- /dev/null +++ b/src/assets/services/radarr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/services/rt.svg b/src/assets/services/rt.svg index a5560ffac..b7792c3aa 100644 --- a/src/assets/services/rt.svg +++ b/src/assets/services/rt.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/services/sonarr.svg b/src/assets/services/sonarr.svg new file mode 100644 index 000000000..465330418 --- /dev/null +++ b/src/assets/services/sonarr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/services/tmdb.svg b/src/assets/services/tmdb.svg index 84537a014..c40f3f7bc 100644 --- a/src/assets/services/tmdb.svg +++ b/src/assets/services/tmdb.svg @@ -1 +1 @@ -Asset 4 \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/services/tvdb.svg b/src/assets/services/tvdb.svg index da2aa9f93..872703a12 100644 --- a/src/assets/services/tvdb.svg +++ b/src/assets/services/tvdb.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/spinner.svg b/src/assets/spinner.svg index 76ac31a81..dde7eb8b4 100644 --- a/src/assets/spinner.svg +++ b/src/assets/spinner.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/tmdb_logo.svg b/src/assets/tmdb_logo.svg index e98e4ab29..bdf988ba7 100644 --- a/src/assets/tmdb_logo.svg +++ b/src/assets/tmdb_logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index ea4f52b36..180bd2ed5 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -3,14 +3,13 @@ import axios from 'axios'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import { MediaStatus } from '../../../server/constants/media'; import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { Collection } from '../../../server/models/Collection'; -import { LanguageContext } from '../../context/LanguageContext'; import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; @@ -48,14 +47,13 @@ const CollectionDetails: React.FC = ({ const router = useRouter(); const settings = useSettings(); const { addToast } = useToasts(); - const { locale } = useContext(LanguageContext); const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); const [isRequesting, setRequesting] = useState(false); const [is4k, setIs4k] = useState(false); const { data, error, revalidate } = useSWR( - `/api/v1/collection/${router.query.collectionId}?language=${locale}`, + `/api/v1/collection/${router.query.collectionId}`, { initialData: collection, revalidateOnMount: true, @@ -63,7 +61,7 @@ const CollectionDetails: React.FC = ({ ); const { data: genres } = useSWR<{ id: number; name: string }[]>( - `/api/v1/genres/movie?language=${locale}` + `/api/v1/genres/movie` ); if (!data && !error) { @@ -110,11 +108,18 @@ const CollectionDetails: React.FC = ({ } const hasRequestable = + hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], { + type: 'or', + }) && data.parts.filter( (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN ).length > 0; const hasRequestable4k = + settings.currentSettings.movie4kEnabled && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { + type: 'or', + }) && data.parts.filter( (part) => !part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN @@ -249,7 +254,7 @@ const CollectionDetails: React.FC = ({ title={intl.formatMessage( is4k ? messages.requestcollection4k : messages.requestcollection )} - iconSvg={} + iconSvg={} >

{intl.formatMessage( @@ -325,55 +330,42 @@ const CollectionDetails: React.FC = ({

- {hasPermission(Permission.REQUEST) && - (hasRequestable || - (settings.currentSettings.movie4kEnabled && - hasPermission( - [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], - { type: 'or' } - ) && - hasRequestable4k)) && ( - { - setRequestModal(true); - setIs4k(!hasRequestable); - }} - text={ - <> - - - {intl.formatMessage( - hasRequestable - ? messages.requestcollection - : messages.requestcollection4k - )} - - - } - > - {settings.currentSettings.movie4kEnabled && - hasPermission( - [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], - { type: 'or' } - ) && - hasRequestable && - hasRequestable4k && ( - { - setRequestModal(true); - setIs4k(true); - }} - > - - - {intl.formatMessage(messages.requestcollection4k)} - - - )} - - )} + {(hasRequestable || hasRequestable4k) && ( + { + setRequestModal(true); + setIs4k(!hasRequestable); + }} + text={ + <> + + + {intl.formatMessage( + hasRequestable + ? messages.requestcollection + : messages.requestcollection4k + )} + + + } + > + {hasRequestable && hasRequestable4k && ( + { + setRequestModal(true); + setIs4k(true); + }} + > + + + {intl.formatMessage(messages.requestcollection4k)} + + + )} + + )}
{data.overview && ( diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index fd9b8918a..ab94650a0 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -81,16 +81,14 @@ function Button

( switch (buttonSize) { case 'sm': - buttonStyle.push('px-2.5 py-1.5 text-xs'); - break; - case 'md': - buttonStyle.push('px-4 py-2 text-sm'); + buttonStyle.push('px-2.5 py-1.5 text-xs button-sm'); break; case 'lg': - buttonStyle.push('px-6 py-3 text-base'); + buttonStyle.push('px-6 py-3 text-base button-lg'); break; + case 'md': default: - buttonStyle.push('px-4 py-2 text-sm'); + buttonStyle.push('px-4 py-2 text-sm button-md'); } buttonStyle.push(className ?? ''); diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 65a8c7417..944c9d8bb 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -19,16 +19,16 @@ const DropdownItem: React.FC = ({ buttonType = 'primary', ...props }) => { - let styleClass = ''; + let styleClass = 'button-md text-white'; switch (buttonType) { case 'ghost': - styleClass = - 'text-white bg-gray-700 hover:bg-gray-600 hover:text-white focus:border-gray-500 focus:text-white'; + styleClass += + ' bg-gray-700 hover:bg-gray-600 focus:border-gray-500 focus:text-white'; break; default: - styleClass = - 'text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:border-indigo-700 focus:text-white'; + styleClass += + ' bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:text-white'; } return ( = ({ useClickOutside(buttonRef, () => setIsOpen(false)); const styleClasses = { - mainButtonClasses: 'text-white border', - dropdownSideButtonClasses: 'border', - dropdownClasses: '', + mainButtonClasses: 'button-md text-white border', + dropdownSideButtonClasses: 'button-md border', + dropdownClasses: 'button-md', }; switch (buttonType) { @@ -70,14 +70,14 @@ const ButtonWithDropdown: React.FC = ({ styleClasses.mainButtonClasses += ' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses; - styleClasses.dropdownClasses = 'bg-gray-700'; + styleClasses.dropdownClasses += ' bg-gray-700'; break; default: styleClasses.mainButtonClasses += ' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue'; styleClasses.dropdownSideButtonClasses += ' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; - styleClasses.dropdownClasses = 'bg-indigo-600'; + styleClasses.dropdownClasses += ' bg-indigo-600'; } return ( @@ -100,11 +100,7 @@ const ButtonWithDropdown: React.FC = ({ aria-label="Expand" onClick={() => setIsOpen((state) => !state)} > - {dropdownIcon ? ( - dropdownIcon - ) : ( - - )} + {dropdownIcon ? dropdownIcon : } = ({ }} >

- {iconSvg && ( -
- {iconSvg} -
- )} + {iconSvg &&
{iconSvg}
}
= ({ links }) => { text={ <> {links[0].svg} - {links[0].text} + {links[0].text} } onClick={() => { @@ -40,7 +40,7 @@ const PlayButton: React.FC = ({ links }) => { buttonType="ghost" > {link.svg} - {link.text} + {link.text} ); })} diff --git a/src/components/Common/SensitiveInput/index.tsx b/src/components/Common/SensitiveInput/index.tsx new file mode 100644 index 000000000..79b8b2e58 --- /dev/null +++ b/src/components/Common/SensitiveInput/index.tsx @@ -0,0 +1,54 @@ +import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid'; +import { Field } from 'formik'; +import React, { useState } from 'react'; + +interface CustomInputProps extends React.ComponentProps<'input'> { + as?: 'input'; +} + +interface CustomFieldProps extends React.ComponentProps { + as?: 'field'; +} + +type SensitiveInputProps = CustomInputProps | CustomFieldProps; + +const SensitiveInput: React.FC = ({ + as = 'input', + ...props +}) => { + const [isHidden, setHidden] = useState(true); + const Component = as === 'input' ? 'input' : Field; + const componentProps = + as === 'input' + ? props + : { + ...props, + as: props.type === 'textarea' && !isHidden ? 'textarea' : undefined, + }; + return ( + <> + + + + ); +}; + +export default SensitiveInput; diff --git a/src/components/Discover/MovieGenreList/index.tsx b/src/components/Discover/MovieGenreList/index.tsx index e7b124160..bc85adad4 100644 --- a/src/components/Discover/MovieGenreList/index.tsx +++ b/src/components/Discover/MovieGenreList/index.tsx @@ -1,24 +1,22 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import GenreCard from '../../GenreCard'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; -import { genreColorMap } from '../constants'; -import PageTitle from '../../Common/PageTitle'; +import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import Error from '../../../pages/_error'; +import PageTitle from '../../Common/PageTitle'; +import GenreCard from '../../GenreCard'; +import { genreColorMap } from '../constants'; const messages = defineMessages({ moviegenres: 'Movie Genres', }); const MovieGenreList: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/movie?language=${locale}` + `/api/v1/discover/genreslider/movie` ); if (!data && !error) { diff --git a/src/components/Discover/MovieGenreSlider/index.tsx b/src/components/Discover/MovieGenreSlider/index.tsx index 56abf7d9a..cf1b8ce1f 100644 --- a/src/components/Discover/MovieGenreSlider/index.tsx +++ b/src/components/Discover/MovieGenreSlider/index.tsx @@ -1,10 +1,9 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import Link from 'next/link'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; import GenreCard from '../../GenreCard'; import Slider from '../../Slider'; import { genreColorMap } from '../constants'; @@ -14,10 +13,9 @@ const messages = defineMessages({ }); const MovieGenreSlider: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/movie?language=${locale}`, + `/api/v1/discover/genreslider/movie`, { refreshInterval: 0, revalidateOnFocus: false, @@ -30,7 +28,7 @@ const MovieGenreSlider: React.FC = () => { {intl.formatMessage(messages.moviegenres)} - +
diff --git a/src/components/Discover/TvGenreList/index.tsx b/src/components/Discover/TvGenreList/index.tsx index 60eabc864..15fe9a017 100644 --- a/src/components/Discover/TvGenreList/index.tsx +++ b/src/components/Discover/TvGenreList/index.tsx @@ -1,24 +1,22 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import GenreCard from '../../GenreCard'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; -import { genreColorMap } from '../constants'; -import PageTitle from '../../Common/PageTitle'; +import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import Error from '../../../pages/_error'; +import PageTitle from '../../Common/PageTitle'; +import GenreCard from '../../GenreCard'; +import { genreColorMap } from '../constants'; const messages = defineMessages({ seriesgenres: 'Series Genres', }); const TvGenreList: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/tv?language=${locale}` + `/api/v1/discover/genreslider/tv` ); if (!data && !error) { diff --git a/src/components/Discover/TvGenreSlider/index.tsx b/src/components/Discover/TvGenreSlider/index.tsx index 37f1ee18b..54f8daa34 100644 --- a/src/components/Discover/TvGenreSlider/index.tsx +++ b/src/components/Discover/TvGenreSlider/index.tsx @@ -1,10 +1,9 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import Link from 'next/link'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; import GenreCard from '../../GenreCard'; import Slider from '../../Slider'; import { genreColorMap } from '../constants'; @@ -14,10 +13,9 @@ const messages = defineMessages({ }); const TvGenreSlider: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/tv?language=${locale}`, + `/api/v1/discover/genreslider/tv`, { refreshInterval: 0, revalidateOnFocus: false, @@ -30,7 +28,7 @@ const TvGenreSlider: React.FC = () => { {intl.formatMessage(messages.tvgenres)} - +
diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index bb80d08b8..0d7b1da73 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -67,7 +67,7 @@ const Discover: React.FC = () => { {intl.formatMessage(messages.recentrequests)} - + diff --git a/src/components/DownloadBlock/index.tsx b/src/components/DownloadBlock/index.tsx index 0075ecb4c..65468f32e 100644 --- a/src/components/DownloadBlock/index.tsx +++ b/src/components/DownloadBlock/index.tsx @@ -1,8 +1,12 @@ import React from 'react'; -import { FormattedRelativeTime } from 'react-intl'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { DownloadingItem } from '../../../server/lib/downloadtracker'; import Badge from '../Common/Badge'; +const messages = defineMessages({ + estimatedtime: 'Estimated {time}', +}); + interface DownloadBlockProps { downloadItem: DownloadingItem; is4k?: boolean; @@ -12,6 +16,8 @@ const DownloadBlock: React.FC = ({ downloadItem, is4k = false, }) => { + const intl = useIntl(); + return (
@@ -48,27 +54,30 @@ const DownloadBlock: React.FC = ({
{is4k && ( - + 4K )} {downloadItem.status} - ETA{' '} - {downloadItem.estimatedCompletionTime ? ( - - ) : ( - 'N/A' - )} + {downloadItem.estimatedCompletionTime + ? intl.formatMessage(messages.estimatedtime, { + time: ( + + ), + }) + : ''}
diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index be37d3d03..2c3357b06 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -1,11 +1,11 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { MediaType } from '../../../server/constants/media'; import ImdbLogo from '../../assets/services/imdb.svg'; import PlexLogo from '../../assets/services/plex.svg'; import RTLogo from '../../assets/services/rt.svg'; import TmdbLogo from '../../assets/services/tmdb.svg'; import TvdbLogo from '../../assets/services/tvdb.svg'; -import { LanguageContext } from '../../context/LanguageContext'; +import useLocale from '../../hooks/useLocale'; interface ExternalLinkBlockProps { mediaType: 'movie' | 'tv'; @@ -24,7 +24,7 @@ const ExternalLinkBlock: React.FC = ({ rtUrl, plexUrl, }) => { - const { locale } = useContext(LanguageContext); + const { locale } = useLocale(); return (
diff --git a/src/components/LanguageSelector/index.tsx b/src/components/LanguageSelector/index.tsx index 89392b319..652687da3 100644 --- a/src/components/LanguageSelector/index.tsx +++ b/src/components/LanguageSelector/index.tsx @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { sortBy } from 'lodash'; import dynamic from 'next/dynamic'; -import React from 'react'; +import React, { useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import type { OptionsType, OptionTypeBase } from 'react-select'; +import useSWR from 'swr'; import { Language } from '../../../server/lib/settings'; import globalMessages from '../../i18n/globalMessages'; @@ -29,7 +31,6 @@ const selectStyles = { }; interface LanguageSelectorProps { - languages: Language[]; value?: string; setFieldValue: (property: string, value: string) => void; serverValue?: string; @@ -37,26 +38,33 @@ interface LanguageSelectorProps { } const LanguageSelector: React.FC = ({ - languages, value, setFieldValue, serverValue, isUserSettings = false, }) => { const intl = useIntl(); + const { data: languages } = useSWR('/api/v1/languages'); - const defaultLanguageNameFallback = serverValue - ? languages.find((language) => language.iso_639_1 === serverValue) - ?.english_name ?? serverValue - : undefined; - - const options: OptionType[] = - languages.map((language) => ({ - label: + const sortedLanguages = useMemo(() => { + languages?.forEach((language) => { + language.name = intl.formatDisplayName(language.iso_639_1, { type: 'language', fallback: 'none', - }) ?? language.english_name, + }) ?? language.english_name; + }); + + return sortBy(languages, 'name'); + }, [intl, languages]); + + const languageName = (languageCode: string) => + sortedLanguages?.find((language) => language.iso_639_1 === languageCode) + ?.name ?? languageCode; + + const options: OptionType[] = + sortedLanguages?.map((language) => ({ + label: language.name, value: language.iso_639_1, })) ?? []; @@ -67,13 +75,7 @@ const LanguageSelector: React.FC = ({ language: serverValue ? serverValue .split('|') - .map( - (value) => - intl.formatDisplayName(value, { - type: 'language', - fallback: 'none', - }) ?? defaultLanguageNameFallback - ) + .map((value) => languageName(value)) .reduce((prev, curr) => intl.formatMessage(globalMessages.delimitedlist, { a: prev, @@ -112,13 +114,7 @@ const LanguageSelector: React.FC = ({ language: serverValue ? serverValue .split('|') - .map( - (value) => - intl.formatDisplayName(value, { - type: 'language', - fallback: 'none', - }) ?? defaultLanguageNameFallback - ) + .map((value) => languageName(value)) .reduce((prev, curr) => intl.formatMessage(globalMessages.delimitedlist, { a: prev, @@ -130,7 +126,7 @@ const LanguageSelector: React.FC = ({ isFixed: true, } : value?.split('|').map((code) => { - const matchedLanguage = languages.find( + const matchedLanguage = sortedLanguages?.find( (lang) => lang.iso_639_1 === code ); @@ -139,11 +135,7 @@ const LanguageSelector: React.FC = ({ } return { - label: - intl.formatDisplayName(matchedLanguage.iso_639_1, { - type: 'language', - fallback: 'none', - }) ?? matchedLanguage.english_name, + label: matchedLanguage.name, value: matchedLanguage.iso_639_1, }; }) ?? undefined diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index 683fe5f43..cd589dde6 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -1,93 +1,22 @@ import { TranslateIcon } from '@heroicons/react/solid'; -import React, { useContext, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { + availableLanguages, AvailableLocales, - LanguageContext, } from '../../../context/LanguageContext'; import useClickOutside from '../../../hooks/useClickOutside'; +import useLocale from '../../../hooks/useLocale'; import Transition from '../../Transition'; const messages = defineMessages({ changelanguage: 'Change Language', }); -type AvailableLanguageObject = Record< - string, - { code: AvailableLocales; display: string } ->; - -const availableLanguages: AvailableLanguageObject = { - ca: { - code: 'ca', - display: 'Català', - }, - de: { - code: 'de', - display: 'Deutsch', - }, - en: { - code: 'en', - display: 'English', - }, - es: { - code: 'es', - display: 'Español', - }, - fr: { - code: 'fr', - display: 'Français', - }, - it: { - code: 'it', - display: 'Italiano', - }, - hu: { - code: 'hu', - display: 'Magyar', - }, - nl: { - code: 'nl', - display: 'Nederlands', - }, - 'nb-NO': { - code: 'nb-NO', - display: 'Norsk Bokmål', - }, - 'pt-BR': { - code: 'pt-BR', - display: 'Português (Brasil)', - }, - 'pt-PT': { - code: 'pt-PT', - display: 'Português (Portugal)', - }, - sv: { - code: 'sv', - display: 'Svenska', - }, - ru: { - code: 'ru', - display: 'pусский', - }, - sr: { - code: 'sr', - display: 'српски језик‬', - }, - ja: { - code: 'ja', - display: '日本語', - }, - 'zh-TW': { - code: 'zh-TW', - display: '中文(臺灣)', - }, -}; - const LanguagePicker: React.FC = () => { const intl = useIntl(); const dropdownRef = useRef(null); - const { locale, setLocale } = useContext(LanguageContext); + const { locale, setLocale } = useLocale(); const [isDropdownOpen, setDropdownOpen] = useState(false); useClickOutside(dropdownRef, () => setDropdownOpen(false)); diff --git a/src/components/Layout/SearchInput/index.tsx b/src/components/Layout/SearchInput/index.tsx index 9042ef45c..1b0aca2aa 100644 --- a/src/components/Layout/SearchInput/index.tsx +++ b/src/components/Layout/SearchInput/index.tsx @@ -27,6 +27,7 @@ const SearchInput: React.FC = () => { className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full focus:border-gray-500 hover:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base" placeholder={intl.formatMessage(messages.searchPlaceholder)} type="search" + inputMode="search" value={searchValue} onChange={(e) => setSearchValue(e.target.value)} onFocus={() => setIsOpen(true)} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 7ea9ac64d..662868354 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -1,10 +1,9 @@ import { MenuAlt2Icon } from '@heroicons/react/outline'; -import { InformationCircleIcon } from '@heroicons/react/solid'; +import { ArrowLeftIcon, InformationCircleIcon } from '@heroicons/react/solid'; import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Permission, useUser } from '../../hooks/useUser'; -import LanguagePicker from './LanguagePicker'; import SearchInput from './SearchInput'; import Sidebar from './Sidebar'; import UserDropdown from './UserDropdown'; @@ -23,7 +22,7 @@ const Layout: React.FC = ({ children }) => { useEffect(() => { const updateScrolled = () => { - if (window.pageYOffset > 60) { + if (window.pageYOffset > 20) { setIsScrolled(true); } else { setIsScrolled(false); @@ -55,16 +54,25 @@ const Layout: React.FC = ({ children }) => { }} > -
+
+
-
diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 6444635f2..71b815fd1 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import * as Yup from 'yup'; import Button from '../Common/Button'; +import SensitiveInput from '../Common/SensitiveInput'; const messages = defineMessages({ email: 'Email Address', @@ -69,7 +70,7 @@ const LocalLogin: React.FC = ({ revalidate }) => { id="email" name="email" type="text" - placeholder="name@example.com" + inputMode="email" />
{errors.email && touched.email && ( @@ -81,12 +82,12 @@ const LocalLogin: React.FC = ({ revalidate }) => {
-
{errors.password && touched.password && ( @@ -104,8 +105,10 @@ const LocalLogin: React.FC = ({ revalidate }) => { @@ -115,10 +118,12 @@ const LocalLogin: React.FC = ({ revalidate }) => { type="submit" disabled={isSubmitting || !isValid} > - - {isSubmitting - ? intl.formatMessage(messages.signingin) - : intl.formatMessage(messages.signin)} + + + {isSubmitting + ? intl.formatMessage(messages.signingin) + : intl.formatMessage(messages.signin)} +
diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 64aa79153..0e3a4feae 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,6 +1,6 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import Link from 'next/link'; -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useSWRInfinite } from 'swr'; import { MediaStatus } from '../../../server/constants/media'; import type { @@ -8,7 +8,6 @@ import type { PersonResult, TvResult, } from '../../../server/models/Search'; -import { LanguageContext } from '../../context/LanguageContext'; import useSettings from '../../hooks/useSettings'; import PersonCard from '../PersonCard'; import Slider from '../Slider'; @@ -38,14 +37,13 @@ const MediaSlider: React.FC = ({ hideWhenEmpty = false, }) => { const settings = useSettings(); - const { locale } = useContext(LanguageContext); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } - return `${url}?page=${pageIndex + 1}&language=${locale}`; + return `${url}?page=${pageIndex + 1}`; }, { initialSize: 2, @@ -141,7 +139,7 @@ const MediaSlider: React.FC = ({ {title} - + ) : ( diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index 081a7a6d7..0cc9c2e03 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -1,15 +1,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { MovieDetails } from '../../../../server/models/Movie'; -import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import PersonCard from '../../PersonCard'; import PageTitle from '../../Common/PageTitle'; +import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcast: 'Full Cast', @@ -18,9 +17,8 @@ const messages = defineMessages({ const MovieCast: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); const { data, error } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); if (!data && !error) { diff --git a/src/components/MovieDetails/MovieCrew/index.tsx b/src/components/MovieDetails/MovieCrew/index.tsx index f19cbc205..14268e425 100644 --- a/src/components/MovieDetails/MovieCrew/index.tsx +++ b/src/components/MovieDetails/MovieCrew/index.tsx @@ -1,15 +1,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { MovieDetails } from '../../../../server/models/Movie'; -import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import PersonCard from '../../PersonCard'; import PageTitle from '../../Common/PageTitle'; +import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcrew: 'Full Crew', @@ -18,9 +17,8 @@ const messages = defineMessages({ const MovieCrew: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); const { data, error } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); if (!data && !error) { diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx index b603e7b5b..fc9c2bf2c 100644 --- a/src/components/MovieDetails/MovieRecommendations.tsx +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -1,16 +1,15 @@ -import React, { useContext } from 'react'; -import useSWR from 'swr'; -import type { MovieResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +import Link from 'next/link'; import { useRouter } from 'next/router'; -import Header from '../Common/Header'; -import type { MovieDetails } from '../../../server/models/Movie'; -import { LanguageContext } from '../../context/LanguageContext'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import PageTitle from '../Common/PageTitle'; +import useSWR from 'swr'; +import type { MovieDetails } from '../../../server/models/Movie'; +import type { MovieResult } from '../../../server/models/Search'; import useDiscover from '../../hooks/useDiscover'; import Error from '../../pages/_error'; -import Link from 'next/link'; +import Header from '../Common/Header'; +import ListView from '../Common/ListView'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ recommendations: 'Recommendations', @@ -19,9 +18,8 @@ const messages = defineMessages({ const MovieRecommendations: React.FC = () => { const intl = useIntl(); const router = useRouter(); - const { locale } = useContext(LanguageContext); const { data: movieData } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); const { isLoadingInitialData, diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx index 93bacc366..8103f966e 100644 --- a/src/components/MovieDetails/MovieSimilar.tsx +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -1,16 +1,15 @@ -import React, { useContext } from 'react'; -import useSWR from 'swr'; -import type { MovieResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +import Link from 'next/link'; import { useRouter } from 'next/router'; -import Header from '../Common/Header'; -import { LanguageContext } from '../../context/LanguageContext'; -import type { MovieDetails } from '../../../server/models/Movie'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import PageTitle from '../Common/PageTitle'; +import useSWR from 'swr'; +import type { MovieDetails } from '../../../server/models/Movie'; +import type { MovieResult } from '../../../server/models/Search'; import useDiscover from '../../hooks/useDiscover'; import Error from '../../pages/_error'; -import Link from 'next/link'; +import Header from '../Common/Header'; +import ListView from '../Common/ListView'; +import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ similar: 'Similar Titles', @@ -19,9 +18,8 @@ const messages = defineMessages({ const MovieSimilar: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); const { data: movieData } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); const { isLoadingInitialData, diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 7db6b9465..eb7de7761 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -12,7 +12,7 @@ import { import axios from 'axios'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import type { RTRating } from '../../../server/api/rottentomatoes'; @@ -23,7 +23,7 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg'; import RTFresh from '../../assets/rt_fresh.svg'; import RTRotten from '../../assets/rt_rotten.svg'; import TmdbLogo from '../../assets/tmdb_logo.svg'; -import { LanguageContext } from '../../context/LanguageContext'; +import useLocale from '../../hooks/useLocale'; import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; @@ -70,9 +70,9 @@ const messages = defineMessages({ openradarr4k: 'Open Movie in 4K Radarr', downloadstatus: 'Download Status', playonplex: 'Play on Plex', - play4konplex: 'Play 4K on Plex', + play4konplex: 'Play in 4K on Plex', markavailable: 'Mark as Available', - mark4kavailable: 'Mark 4K as Available', + mark4kavailable: 'Mark as Available in 4K', }); interface MovieDetailsProps { @@ -84,11 +84,11 @@ const MovieDetails: React.FC = ({ movie }) => { const { user, hasPermission } = useUser(); const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); + const { locale } = useLocale(); const [showManager, setShowManager] = useState(false); const { data, error, revalidate } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}`, + `/api/v1/movie/${router.query.movieId}`, { initialData: movie, } @@ -112,11 +112,16 @@ const MovieDetails: React.FC = ({ movie }) => { const mediaLinks: PlayButtonLink[] = []; - if (data.mediaInfo?.plexUrl) { + if ( + data.mediaInfo?.plexUrl && + hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], { + type: 'or', + }) + ) { mediaLinks.push({ text: intl.formatMessage(messages.playonplex), url: data.mediaInfo?.plexUrl, - svg: , + svg: , }); } @@ -129,7 +134,7 @@ const MovieDetails: React.FC = ({ movie }) => { mediaLinks.push({ text: intl.formatMessage(messages.play4konplex), url: data.mediaInfo?.plexUrl4k, - svg: , + svg: , }); } @@ -142,7 +147,7 @@ const MovieDetails: React.FC = ({ movie }) => { mediaLinks.push({ text: intl.formatMessage(messages.watchtrailer), url: trailerUrl, - svg: , + svg: , }); } @@ -280,7 +285,7 @@ const MovieDetails: React.FC = ({ movie }) => { className="w-full sm:mb-0" buttonType="success" > - + {intl.formatMessage(messages.markavailable)}
@@ -294,7 +299,7 @@ const MovieDetails: React.FC = ({ movie }) => { className="w-full sm:mb-0" buttonType="success" > - + {intl.formatMessage(messages.mark4kavailable)} @@ -333,7 +338,7 @@ const MovieDetails: React.FC = ({ movie }) => { className="block mb-2 last:mb-0" > @@ -345,7 +350,7 @@ const MovieDetails: React.FC = ({ movie }) => { rel="noreferrer" > @@ -359,10 +364,10 @@ const MovieDetails: React.FC = ({ movie }) => { confirmText={intl.formatMessage(globalMessages.areyousure)} className="w-full" > - - {intl.formatMessage(messages.manageModalClearMedia)} + + {intl.formatMessage(messages.manageModalClearMedia)} -
+
{intl.formatMessage(messages.manageModalClearMediaWarning)}
@@ -440,7 +445,7 @@ const MovieDetails: React.FC = ({ movie }) => { className="ml-2 first:ml-0" onClick={() => setShowManager(true)} > - + )}
@@ -470,7 +475,7 @@ const MovieDetails: React.FC = ({ movie }) => { {intl.formatMessage(messages.viewfullcrew)} - + @@ -653,7 +658,7 @@ const MovieDetails: React.FC = ({ movie }) => { {intl.formatMessage(messages.cast)} - + diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx index b549613f7..273500070 100644 --- a/src/components/NotificationTypeSelector/index.tsx +++ b/src/components/NotificationTypeSelector/index.tsx @@ -53,6 +53,10 @@ export enum Notification { MEDIA_AUTO_APPROVED = 128, } +export const ALL_NOTIFICATIONS = Object.values(Notification) + .filter((v) => !isNaN(Number(v))) + .reduce((a, v) => a + Number(v), 0); + export interface NotificationItem { id: string; name: string; diff --git a/src/components/PWAHeader/index.tsx b/src/components/PWAHeader/index.tsx new file mode 100644 index 000000000..d8a9eba13 --- /dev/null +++ b/src/components/PWAHeader/index.tsx @@ -0,0 +1,183 @@ +import React from 'react'; + +interface PWAHeaderProps { + applicationTitle?: string; +} + +const PWAHeader: React.FC = ({ applicationTitle }) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default PWAHeader; diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index 5c0f60fe8..71c6fc8b5 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -12,42 +12,41 @@ export const messages = defineMessages({ 'Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.', settings: 'Manage Settings', settingsDescription: - 'Grant permission to modify all Overseerr settings. A user must have this permission to grant it to others.', + 'Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.', managerequests: 'Manage Requests', managerequestsDescription: - 'Grant permission to manage Overseerr requests (includes approving and denying requests). All requests made by a user with this permission will be automatically approved.', + 'Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.', request: 'Request', - requestDescription: 'Grant permission to request movies and series.', - vote: 'Vote', - voteDescription: - 'Grant permission to vote on requests (voting not yet implemented).', + requestDescription: 'Grant permission to request non-4K media.', + requestMovies: 'Request Movies', + requestMoviesDescription: 'Grant permission to request non-4K movies.', + requestTv: 'Request Series', + requestTvDescription: 'Grant permission to request non-4K series.', autoapprove: 'Auto-Approve', - autoapproveDescription: - 'Grant automatic approval for all non-4K requests made by this user.', + autoapproveDescription: 'Grant automatic approval for all non-4K requests.', autoapproveMovies: 'Auto-Approve Movies', autoapproveMoviesDescription: - 'Grant automatic approval for non-4K movie requests made by this user.', + 'Grant automatic approval for non-4K movie requests.', autoapproveSeries: 'Auto-Approve Series', autoapproveSeriesDescription: - 'Grant automatic approval for non-4K series requests made by this user.', + 'Grant automatic approval for non-4K series requests.', autoapprove4k: 'Auto-Approve 4K', - autoapprove4kDescription: - 'Grant automatic approval for all 4K requests made by this user.', + autoapprove4kDescription: 'Grant automatic approval for all 4K requests.', autoapprove4kMovies: 'Auto-Approve 4K Movies', autoapprove4kMoviesDescription: - 'Grant automatic approval for 4K movie requests made by this user.', + 'Grant automatic approval for 4K movie requests.', autoapprove4kSeries: 'Auto-Approve 4K Series', autoapprove4kSeriesDescription: - 'Grant automatic approval for 4K series requests made by this user.', + 'Grant automatic approval for 4K series requests.', request4k: 'Request 4K', - request4kDescription: 'Grant permission to request 4K movies and series.', + request4kDescription: 'Grant permission to request 4K media.', request4kMovies: 'Request 4K Movies', request4kMoviesDescription: 'Grant permission to request 4K movies.', request4kTv: 'Request 4K Series', - request4kTvDescription: 'Grant permission to request 4K Series.', + request4kTvDescription: 'Grant permission to request 4K series.', advancedrequest: 'Advanced Requests', advancedrequestDescription: - 'Grant permission to use advanced request options (e.g., changing servers, profiles, or paths).', + 'Grant permission to use advanced request options.', viewrequests: 'View Requests', viewrequestsDescription: "Grant permission to view other users' requests.", }); @@ -111,27 +110,18 @@ export const PermissionEdit: React.FC = ({ name: intl.formatMessage(messages.request), description: intl.formatMessage(messages.requestDescription), permission: Permission.REQUEST, - }, - { - id: 'request4k', - name: intl.formatMessage(messages.request4k), - description: intl.formatMessage(messages.request4kDescription), - permission: Permission.REQUEST_4K, - requires: [{ permissions: [Permission.REQUEST] }], children: [ { - id: 'request4k-movies', - name: intl.formatMessage(messages.request4kMovies), - description: intl.formatMessage(messages.request4kMoviesDescription), - permission: Permission.REQUEST_4K_MOVIE, - requires: [{ permissions: [Permission.REQUEST] }], + id: 'request-movies', + name: intl.formatMessage(messages.requestMovies), + description: intl.formatMessage(messages.requestMoviesDescription), + permission: Permission.REQUEST_MOVIE, }, { - id: 'request4k-tv', - name: intl.formatMessage(messages.request4kTv), - description: intl.formatMessage(messages.request4kTvDescription), - permission: Permission.REQUEST_4K_TV, - requires: [{ permissions: [Permission.REQUEST] }], + id: 'request-tv', + name: intl.formatMessage(messages.requestTv), + description: intl.formatMessage(messages.requestTvDescription), + permission: Permission.REQUEST_TV, }, ], }, @@ -149,7 +139,12 @@ export const PermissionEdit: React.FC = ({ messages.autoapproveMoviesDescription ), permission: Permission.AUTO_APPROVE_MOVIE, - requires: [{ permissions: [Permission.REQUEST] }], + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE], + type: 'or', + }, + ], }, { id: 'autoapprovetv', @@ -158,7 +153,32 @@ export const PermissionEdit: React.FC = ({ messages.autoapproveSeriesDescription ), permission: Permission.AUTO_APPROVE_TV, - requires: [{ permissions: [Permission.REQUEST] }], + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_TV], + type: 'or', + }, + ], + }, + ], + }, + { + id: 'request4k', + name: intl.formatMessage(messages.request4k), + description: intl.formatMessage(messages.request4kDescription), + permission: Permission.REQUEST_4K, + children: [ + { + id: 'request4k-movies', + name: intl.formatMessage(messages.request4kMovies), + description: intl.formatMessage(messages.request4kMoviesDescription), + permission: Permission.REQUEST_4K_MOVIE, + }, + { + id: 'request4k-tv', + name: intl.formatMessage(messages.request4kTv), + description: intl.formatMessage(messages.request4kTvDescription), + permission: Permission.REQUEST_4K_TV, }, ], }, @@ -169,8 +189,7 @@ export const PermissionEdit: React.FC = ({ permission: Permission.AUTO_APPROVE_4K, requires: [ { - permissions: [Permission.REQUEST, Permission.REQUEST_4K], - type: 'and', + permissions: [Permission.REQUEST_4K], }, ], children: [ @@ -182,9 +201,6 @@ export const PermissionEdit: React.FC = ({ ), permission: Permission.AUTO_APPROVE_4K_MOVIE, requires: [ - { - permissions: [Permission.REQUEST], - }, { permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], type: 'or', @@ -199,9 +215,6 @@ export const PermissionEdit: React.FC = ({ ), permission: Permission.AUTO_APPROVE_4K_TV, requires: [ - { - permissions: [Permission.REQUEST], - }, { permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_TV], type: 'or', diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index 314c9944f..35b0a6555 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { hasPermission } from '../../../server/lib/permissions'; -import { Permission, User } from '../../hooks/useUser'; import useSettings from '../../hooks/useSettings'; +import { Permission, User } from '../../hooks/useUser'; export interface PermissionItem { id: string; diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index a0082c79c..3ea148c06 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -1,13 +1,12 @@ import { groupBy } from 'lodash'; import { useRouter } from 'next/router'; -import React, { useContext, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import TruncateMarkup from 'react-truncate-markup'; import useSWR from 'swr'; import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces'; import type { PersonDetail } from '../../../server/models/Person'; import Ellipsis from '../../assets/ellipsis.svg'; -import { LanguageContext } from '../../context/LanguageContext'; import globalMessages from '../../i18n/globalMessages'; import Error from '../../pages/_error'; import CachedImage from '../Common/CachedImage'; @@ -27,10 +26,9 @@ const messages = defineMessages({ const PersonDetails: React.FC = () => { const intl = useIntl(); - const { locale } = useContext(LanguageContext); const router = useRouter(); const { data, error } = useSWR( - `/api/v1/person/${router.query.personId}?language=${locale}` + `/api/v1/person/${router.query.personId}` ); const [showBio, setShowBio] = useState(false); @@ -38,7 +36,7 @@ const PersonDetails: React.FC = () => { data: combinedCredits, error: errorCombinedCredits, } = useSWR( - `/api/v1/person/${router.query.personId}/combined_credits?language=${locale}` + `/api/v1/person/${router.query.personId}/combined_credits` ); const sortedCast = useMemo(() => { diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx index c85fa78c6..550938716 100644 --- a/src/components/PlexLoginButton/index.tsx +++ b/src/components/PlexLoginButton/index.tsx @@ -49,12 +49,14 @@ const PlexLoginButton: React.FC = ({ disabled={loading || isProcessing} className="plex-button" > - - {loading - ? intl.formatMessage(globalMessages.loading) - : isProcessing - ? intl.formatMessage(messages.signingin) - : intl.formatMessage(messages.signinwithplex)} + + + {loading + ? intl.formatMessage(globalMessages.loading) + : isProcessing + ? intl.formatMessage(messages.signingin) + : intl.formatMessage(messages.signinwithplex)} + ); diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx index dbbae3f93..ac44079ce 100644 --- a/src/components/RegionSelector/index.tsx +++ b/src/components/RegionSelector/index.tsx @@ -2,6 +2,7 @@ import { Listbox, Transition } from '@headlessui/react'; import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid'; import { hasFlag } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; +import { sortBy } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -39,32 +40,21 @@ const RegionSelector: React.FC = ({ [] ); - const sortedRegions = useMemo( - () => - regions?.sort((region1, region2) => { - const region1Name = - intl.formatDisplayName(region1.iso_3166_1, { - type: 'region', - fallback: 'none', - }) ?? region1.english_name; - const region2Name = - intl.formatDisplayName(region2.iso_3166_1, { - type: 'region', - fallback: 'none', - }) ?? region2.english_name; + const sortedRegions = useMemo(() => { + regions?.forEach((region) => { + region.name = + intl.formatDisplayName(region.iso_3166_1, { + type: 'region', + fallback: 'none', + }) ?? region.english_name; + }); - return region1Name === region2Name - ? 0 - : region1Name > region2Name - ? 1 - : -1; - }), - [intl, regions] - ); + return sortBy(regions, 'name'); + }, [intl, regions]); - const defaultRegionNameFallback = - regions?.find((region) => region.iso_3166_1 === currentSettings.region) - ?.english_name ?? currentSettings.region; + const regionName = (regionCode: string) => + sortedRegions?.find((region) => region.iso_3166_1 === regionCode)?.name ?? + regionCode; useEffect(() => { if (regions && value) { @@ -86,124 +76,145 @@ const RegionSelector: React.FC = ({ }, [onChange, selectedRegion, name, regions]); return ( -
-
- - {({ open }) => ( -
- - - {((selectedRegion && hasFlag(selectedRegion?.iso_3166_1)) || - (isUserSetting && - !selectedRegion && - currentSettings.region && - hasFlag(currentSettings.region))) && ( - - - - )} - - {selectedRegion && selectedRegion.iso_3166_1 !== 'all' - ? intl.formatDisplayName(selectedRegion.iso_3166_1, { - type: 'region', - fallback: 'none', - }) ?? selectedRegion.english_name - : isUserSetting && selectedRegion?.iso_3166_1 !== 'all' - ? intl.formatMessage(messages.regionServerDefault, { - region: currentSettings.region - ? intl.formatDisplayName(currentSettings.region, { - type: 'region', - fallback: 'none', - }) ?? defaultRegionNameFallback - : intl.formatMessage(messages.regionDefault), - }) - : intl.formatMessage(messages.regionDefault)} +
+ + {({ open }) => ( +
+ + + {((selectedRegion && hasFlag(selectedRegion?.iso_3166_1)) || + (isUserSetting && + !selectedRegion && + currentSettings.region && + hasFlag(currentSettings.region))) && ( + + - - - - - + )} + + {selectedRegion && selectedRegion.iso_3166_1 !== 'all' + ? regionName(selectedRegion.iso_3166_1) + : isUserSetting && selectedRegion?.iso_3166_1 !== 'all' + ? intl.formatMessage(messages.regionServerDefault, { + region: currentSettings.region + ? regionName(currentSettings.region) + : intl.formatMessage(messages.regionDefault), + }) + : intl.formatMessage(messages.regionDefault)} + + + + + + - + - - {isUserSetting && ( - - {({ selected, active }) => ( -
+ {({ selected, active }) => ( +
+ + + + - - - + {intl.formatMessage(messages.regionServerDefault, { + region: currentSettings.region + ? regionName(currentSettings.region) + : intl.formatMessage(messages.regionDefault), + })} + + {selected && ( - {intl.formatMessage(messages.regionServerDefault, { - region: currentSettings.region - ? intl.formatDisplayName( - currentSettings.region, - { - type: 'region', - fallback: 'none', - } - ) ?? defaultRegionNameFallback - : intl.formatMessage(messages.regionDefault), - })} + - {selected && ( - - - - )} -
+ )} +
+ )} +
+ )} + + {({ selected, active }) => ( +
+ + {intl.formatMessage(messages.regionDefault)} + + {selected && ( + + + )} - +
)} - + + {sortedRegions?.map((region) => ( + {({ selected, active }) => (
+ + + - {intl.formatMessage(messages.regionDefault)} + {regionName(region.iso_3166_1)} {selected && ( = ({
)}
- {sortedRegions?.map((region) => ( - - {({ selected, active }) => ( -
- - - - - {intl.formatDisplayName(region.iso_3166_1, { - type: 'region', - fallback: 'none', - }) ?? region.english_name} - - {selected && ( - - - - )} -
- )} -
- ))} -
-
-
- )} -
-
+ ))} + + +
+ )} +
); }; diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index d003bcf72..cd07e1ebf 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -8,6 +8,7 @@ import { XIcon, } from '@heroicons/react/solid'; import axios from 'axios'; +import Link from 'next/link'; import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { MediaRequestStatus } from '../../../server/constants/media'; @@ -80,14 +81,22 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => {
- {request.requestedBy.displayName} + + + {request.requestedBy.displayName} + +
{request.modifiedBy && (
- {request.modifiedBy?.displayName} + + + {request.modifiedBy.displayName} + +
)} @@ -95,33 +104,29 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => {
{request.status === MediaRequestStatus.PENDING && ( <> - - - - - - - - - + + + )} {request.status !== MediaRequestStatus.PENDING && ( @@ -130,7 +135,7 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => { onClick={() => deleteRequest()} disabled={isUpdating} > - + )}
@@ -190,7 +195,7 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => {
)} - {(server || profile || rootFolder) && ( + {(server || profile !== null || rootFolder) && ( <>
{intl.formatMessage(messages.requestoverrides)} diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index a528bba43..5ba5bf5d5 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -123,7 +123,15 @@ const RequestButton: React.FC = ({ const buttons: ButtonOption[] = []; if ( (!media || media.status === MediaStatus.UNKNOWN) && - hasPermission(Permission.REQUEST) + hasPermission( + [ + Permission.REQUEST, + mediaType === 'movie' + ? Permission.REQUEST_MOVIE + : Permission.REQUEST_TV, + ], + { type: 'or' } + ) ) { buttons.push({ id: 'request', @@ -132,15 +140,21 @@ const RequestButton: React.FC = ({ setEditRequest(false); setShowRequestModal(true); }, - svg: , + svg: , }); } if ( (!media || media.status4k === MediaStatus.UNKNOWN) && - (hasPermission(Permission.REQUEST_4K) || - (mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) || - (mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) && + hasPermission( + [ + Permission.REQUEST_4K, + mediaType === 'movie' + ? Permission.REQUEST_4K_MOVIE + : Permission.REQUEST_4K_TV, + ], + { type: 'or' } + ) && ((settings.currentSettings.movie4kEnabled && mediaType === 'movie') || (settings.currentSettings.series4kEnabled && mediaType === 'tv')) ) { @@ -151,7 +165,7 @@ const RequestButton: React.FC = ({ setEditRequest(false); setShowRequest4kModal(true); }, - svg: , + svg: , }); } @@ -168,7 +182,7 @@ const RequestButton: React.FC = ({ setEditRequest(true); setShowRequestModal(true); }, - svg: , + svg: , }); } @@ -185,7 +199,7 @@ const RequestButton: React.FC = ({ setEditRequest(true); setShowRequest4kModal(true); }, - svg: , + svg: , }); } @@ -201,7 +215,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequest(activeRequest, 'approve'); }, - svg: , + svg: , }, { id: 'decline-request', @@ -209,7 +223,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequest(activeRequest, 'decline'); }, - svg: , + svg: , } ); } @@ -229,7 +243,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequests(activeRequests, 'approve'); }, - svg: , + svg: , }, { id: 'decline-request-batch', @@ -239,7 +253,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequests(activeRequests, 'decline'); }, - svg: , + svg: , } ); } @@ -256,7 +270,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequest(active4kRequest, 'approve'); }, - svg: , + svg: , }, { id: 'decline-4k-request', @@ -264,7 +278,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequest(active4kRequest, 'decline'); }, - svg: , + svg: , } ); } @@ -284,7 +298,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequests(active4kRequests, 'approve'); }, - svg: , + svg: , }, { id: 'decline-4k-request-batch', @@ -294,7 +308,7 @@ const RequestButton: React.FC = ({ action: () => { modifyRequests(active4kRequests, 'decline'); }, - svg: , + svg: , } ); } @@ -302,7 +316,9 @@ const RequestButton: React.FC = ({ if ( mediaType === 'tv' && (!activeRequest || activeRequest.requestedBy.id !== user?.id) && - hasPermission(Permission.REQUEST) && + hasPermission([Permission.REQUEST, Permission.REQUEST_TV], { + type: 'or', + }) && media && media.status !== MediaStatus.AVAILABLE && media.status !== MediaStatus.UNKNOWN && @@ -315,7 +331,7 @@ const RequestButton: React.FC = ({ setEditRequest(false); setShowRequestModal(true); }, - svg: , + svg: , }); } @@ -338,7 +354,7 @@ const RequestButton: React.FC = ({ setEditRequest(false); setShowRequest4kModal(true); }, - svg: , + svg: , }); } @@ -376,8 +392,8 @@ const RequestButton: React.FC = ({ - {buttonOne.svg ?? null} - {buttonOne.text} + {buttonOne.svg} + {buttonOne.text} } onClick={buttonOne.action} @@ -390,7 +406,7 @@ const RequestButton: React.FC = ({ key={`request-option-${button.id}`} > {button.svg} - {button.text} + {button.text} )) : null} diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 7e71813e3..afa6216e6 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -1,7 +1,7 @@ import { CheckIcon, TrashIcon, XIcon } from '@heroicons/react/solid'; import axios from 'axios'; import Link from 'next/link'; -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useInView } from 'react-intersection-observer'; import { defineMessages, useIntl } from 'react-intl'; import useSWR, { mutate } from 'swr'; @@ -12,7 +12,6 @@ import { import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { MovieDetails } from '../../../server/models/Movie'; import type { TvDetails } from '../../../server/models/Tv'; -import { LanguageContext } from '../../context/LanguageContext'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; import { withProperties } from '../../utils/typeHelpers'; @@ -63,16 +62,15 @@ const RequestCardError: React.FC = ({ mediaId }) => { {intl.formatMessage(messages.mediaerror)}
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && ( -
- -
+ )} @@ -92,13 +90,12 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { }); const intl = useIntl(); const { hasPermission } = useUser(); - const { locale } = useContext(LanguageContext); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}?language=${locale}` : null + inView ? `${url}` : null ); const { data: requestData, @@ -242,31 +239,27 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { {requestData.status === MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( -
- - - - - - +
+ +
)}
diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 01fb1ddc1..6642f54d6 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -7,7 +7,7 @@ import { } from '@heroicons/react/solid'; import axios from 'axios'; import Link from 'next/link'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -19,7 +19,6 @@ import { import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import type { MovieDetails } from '../../../../server/models/Movie'; import type { TvDetails } from '../../../../server/models/Tv'; -import { LanguageContext } from '../../../context/LanguageContext'; import { Permission, useUser } from '../../../hooks/useUser'; import globalMessages from '../../../i18n/globalMessages'; import Badge from '../../Common/Badge'; @@ -74,7 +73,7 @@ const RequestItemError: React.FC = ({ buttonSize="sm" onClick={() => deleteRequest()} > - + {intl.formatMessage(messages.deleterequest)} @@ -99,13 +98,12 @@ const RequestItem: React.FC = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const [showEditModal, setShowEditModal] = useState(false); - const { locale } = useContext(LanguageContext); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}?language=${locale}` : null + inView ? `${url}` : null ); const { data: requestData, revalidate, mutate } = useSWR( `/api/v1/request/${request.id}`, @@ -375,10 +373,10 @@ const RequestItem: React.FC = ({ onClick={() => retryRequest()} > - + {intl.formatMessage( isRetrying ? globalMessages.retrying : globalMessages.retry )} @@ -392,10 +390,8 @@ const RequestItem: React.FC = ({ confirmText={intl.formatMessage(globalMessages.areyousure)} className="w-full" > - - - {intl.formatMessage(messages.deleterequest)} - + + {intl.formatMessage(messages.deleterequest)} )} {requestData.status === MediaRequestStatus.PENDING && @@ -407,10 +403,8 @@ const RequestItem: React.FC = ({ buttonType="success" onClick={() => modifyRequest('approve')} > - - - {intl.formatMessage(globalMessages.approve)} - + + {intl.formatMessage(globalMessages.approve)} @@ -419,10 +413,8 @@ const RequestItem: React.FC = ({ buttonType="danger" onClick={() => modifyRequest('decline')} > - - - {intl.formatMessage(globalMessages.decline)} - + + {intl.formatMessage(globalMessages.decline)} @@ -438,10 +430,8 @@ const RequestItem: React.FC = ({ buttonType="primary" onClick={() => setShowEditModal(true)} > - - - {intl.formatMessage(messages.editrequest)} - + + {intl.formatMessage(messages.editrequest)} )} @@ -453,10 +443,8 @@ const RequestItem: React.FC = ({ confirmText={intl.formatMessage(globalMessages.areyousure)} className="w-full" > - - - {intl.formatMessage(messages.cancelRequest)} - + + {intl.formatMessage(messages.cancelRequest)} )} diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index cfdbb0073..8ed0adf4d 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -1,10 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { Listbox, Transition } from '@headlessui/react'; -import { - AdjustmentsIcon, - CheckIcon, - ChevronDownIcon, -} from '@heroicons/react/solid'; +import { AdjustmentsIcon } from '@heroicons/react/outline'; +import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid'; import { isEqual } from 'lodash'; import dynamic from 'next/dynamic'; import React, { useEffect, useState } from 'react'; @@ -274,7 +271,7 @@ const AdvancedRequester: React.FC = ({ return ( <>
- + {intl.formatMessage(messages.advancedoptions)}
@@ -522,8 +519,8 @@ const AdvancedRequester: React.FC = ({ ({selectedUser.email}) - - + + diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index bd2512aee..11d4e9e0f 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -236,7 +236,7 @@ const MovieRequestModal: React.FC = ({ } secondaryButtonType="danger" cancelText={intl.formatMessage(globalMessages.close)} - iconSvg={} + iconSvg={} > {isOwner ? intl.formatMessage(messages.pendingapproval) @@ -294,7 +294,7 @@ const MovieRequestModal: React.FC = ({ ) } okButtonType={'primary'} - iconSvg={} + iconSvg={} > {hasAutoApprove && !quota?.movie.restricted && (
diff --git a/src/components/RequestModal/SearchByNameModal/index.tsx b/src/components/RequestModal/SearchByNameModal/index.tsx index 111d6137f..fad62198e 100644 --- a/src/components/RequestModal/SearchByNameModal/index.tsx +++ b/src/components/RequestModal/SearchByNameModal/index.tsx @@ -52,7 +52,7 @@ const SearchByNameModal: React.FC = ({ okText={intl.formatMessage(globalMessages.next)} okDisabled={!tvdbId} okButtonType="primary" - iconSvg={} + iconSvg={} > = ({ ? intl.formatMessage(globalMessages.back) : intl.formatMessage(globalMessages.cancel) } - iconSvg={} + iconSvg={} > {editRequest ? isOwner diff --git a/src/components/ResetPassword/RequestResetLink.tsx b/src/components/ResetPassword/RequestResetLink.tsx index 74c342fa2..0cf20abb7 100644 --- a/src/components/ResetPassword/RequestResetLink.tsx +++ b/src/components/ResetPassword/RequestResetLink.tsx @@ -107,7 +107,7 @@ const ResetPassword: React.FC = () => { id="email" name="email" type="text" - placeholder="name@example.com" + inputMode="email" className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
@@ -124,8 +124,10 @@ const ResetPassword: React.FC = () => { type="submit" disabled={isSubmitting || !isValid} > - - {intl.formatMessage(messages.emailresetlink)} + + + {intl.formatMessage(messages.emailresetlink)} +
diff --git a/src/components/ResetPassword/index.tsx b/src/components/ResetPassword/index.tsx index 94005ab1d..2dc9bfb07 100644 --- a/src/components/ResetPassword/index.tsx +++ b/src/components/ResetPassword/index.tsx @@ -1,5 +1,5 @@ import axios from 'axios'; -import { Field, Form, Formik } from 'formik'; +import { Form, Formik } from 'formik'; import Link from 'next/link'; import { useRouter } from 'next/router'; import React, { useState } from 'react'; @@ -7,6 +7,7 @@ import { defineMessages, useIntl } from 'react-intl'; import * as Yup from 'yup'; import Button from '../Common/Button'; import ImageFader from '../Common/ImageFader'; +import SensitiveInput from '../Common/SensitiveInput'; import LanguagePicker from '../Layout/LanguagePicker'; const messages = defineMessages({ @@ -116,7 +117,8 @@ const ResetPassword: React.FC = () => {
- {
- { + const { currentSettings } = useSettings(); + const { user } = useUser(); + useEffect(() => { + if ('serviceWorker' in navigator && user?.id) { + navigator.serviceWorker + .register('/sw.js') + .then(async (registration) => { + console.log( + '[SW] Registration successful, scope is:', + registration.scope + ); + + if (currentSettings.enablePushRegistration) { + const sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: currentSettings.vapidPublic, + }); + + const parsedSub = JSON.parse(JSON.stringify(sub)); + + if (parsedSub.keys.p256dh && parsedSub.keys.auth) { + await axios.post('/api/v1/user/registerPushSubscription', { + endpoint: parsedSub.endpoint, + p256dh: parsedSub.keys.p256dh, + auth: parsedSub.keys.auth, + }); + } + } + }) + .catch(function (error) { + console.log('[SW] Service worker registration failed, error:', error); + }); + } + }, [ + user, + currentSettings.vapidPublic, + currentSettings.enablePushRegistration, + ]); + return null; +}; + +export default ServiceWorkerSetup; diff --git a/src/components/Settings/CopyButton.tsx b/src/components/Settings/CopyButton.tsx index 4ae21190a..bd9738a29 100644 --- a/src/components/Settings/CopyButton.tsx +++ b/src/components/Settings/CopyButton.tsx @@ -30,9 +30,9 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => { e.preventDefault(); setCopied(); }} - 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 hover:bg-indigo-500 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700" + className="input-action" > - + ); }; diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index b70baf286..70427f699 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -1,6 +1,6 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -15,16 +15,20 @@ const messages = defineMessages({ botUsername: 'Bot Username', botAvatarUrl: 'Bot Avatar URL', webhookUrl: 'Webhook URL', - webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks', + webhookUrlTip: + 'Create a webhook integration in your server', discordsettingssaved: 'Discord notification settings saved successfully!', discordsettingsfailed: 'Discord notification settings failed to save.', - discordtestsent: 'Discord test notification sent!', + toastDiscordTestSending: 'Sending Discord test notification…', + toastDiscordTestSuccess: 'Discord test notification sent!', + toastDiscordTestFailed: 'Discord test notification failed to send.', validationUrl: 'You must provide a valid URL', }); const NotificationsDiscord: React.FC = () => { const intl = useIntl(); - const { addToast } = useToasts(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/discord' ); @@ -86,20 +90,47 @@ const NotificationsDiscord: React.FC = () => { > {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => { const testSettings = async () => { - await axios.post('/api/v1/settings/notifications/discord/test', { - enabled: true, - types: values.types, - options: { - botUsername: values.botUsername, - botAvatarUrl: values.botAvatarUrl, - webhookUrl: values.webhookUrl, - }, - }); + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastDiscordTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/discord/test', { + enabled: true, + types: values.types, + options: { + botUsername: values.botUsername, + botAvatarUrl: values.botAvatarUrl, + webhookUrl: values.webhookUrl, + }, + }); - addToast(intl.formatMessage(messages.discordtestsent), { - appearance: 'info', - autoDismiss: true, - }); + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastDiscordTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastDiscordTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } }; return ( @@ -107,65 +138,75 @@ const NotificationsDiscord: React.FC = () => {
-
-
-
@@ -178,21 +219,22 @@ const NotificationsDiscord: React.FC = () => {
-
- -
-
- -
-
-
{errors.smtpHost && touched.smtpHost && ( @@ -303,7 +344,7 @@ const NotificationsEmail: React.FC = () => { id="smtpPort" name="smtpPort" type="text" - placeholder="465" + inputMode="numeric" className="short" /> {errors.smtpPort && touched.smtpPort && ( @@ -312,14 +353,29 @@ const NotificationsEmail: React.FC = () => {
-
@@ -350,11 +406,11 @@ const NotificationsEmail: React.FC = () => {
-
@@ -375,10 +431,11 @@ const NotificationsEmail: React.FC = () => {
- @@ -404,11 +461,11 @@ const NotificationsEmail: React.FC = () => {
-
{errors.pgpPassword && touched.pgpPassword && ( @@ -425,21 +482,22 @@ const NotificationsEmail: React.FC = () => { + + + + +
+
+ + ); + }} + + ); +}; + +export default NotificationsLunaSea; diff --git a/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx b/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx index 85db2cd57..e6d877ba9 100644 --- a/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx +++ b/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx @@ -1,31 +1,34 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; import globalMessages from '../../../../i18n/globalMessages'; -import Alert from '../../../Common/Alert'; import Button from '../../../Common/Button'; import LoadingSpinner from '../../../Common/LoadingSpinner'; +import SensitiveInput from '../../../Common/SensitiveInput'; import NotificationTypeSelector from '../../../NotificationTypeSelector'; const messages = defineMessages({ agentEnabled: 'Enable Agent', accessToken: 'Access Token', + accessTokenTip: + 'Create a token from your Account Settings', validationAccessTokenRequired: 'You must provide an access token', pushbulletSettingsSaved: 'Pushbullet notification settings saved successfully!', pushbulletSettingsFailed: 'Pushbullet notification settings failed to save.', - testSent: 'Pushbullet test notification sent!', - settingUpPushbulletDescription: - 'To configure Pushbullet notifications, you will need to create an access token.', + toastPushbulletTestSending: 'Sending Pushbullet test notification…', + toastPushbulletTestSuccess: 'Pushbullet test notification sent!', + toastPushbulletTestFailed: 'Pushbullet test notification failed to send.', }); const NotificationsPushbullet: React.FC = () => { const intl = useIntl(); - const { addToast } = useToasts(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/pushbullet' ); @@ -77,104 +80,129 @@ const NotificationsPushbullet: React.FC = () => { > {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => { const testSettings = async () => { - await axios.post('/api/v1/settings/notifications/pushbullet/test', { - enabled: true, - types: values.types, - options: { - accessToken: values.accessToken, - }, - }); + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastPushbulletTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/pushbullet/test', { + enabled: true, + types: values.types, + options: { + accessToken: values.accessToken, + }, + }); - addToast(intl.formatMessage(messages.testSent), { - appearance: 'info', - autoDismiss: true, - }); + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPushbulletTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPushbulletTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } }; return ( - <> - - {msg} - - ); - }, - } - )} - type="info" - /> -
-
- -
- -
+ +
+ +
+
-
- -
-
- -
- {errors.accessToken && touched.accessToken && ( -
{errors.accessToken}
- )} +
+
+ +
+
+
+ {errors.accessToken && touched.accessToken && ( +
{errors.accessToken}
+ )}
- setFieldValue('types', newTypes)} - /> -
-
- - - - - - -
+
+ setFieldValue('types', newTypes)} + /> +
+
+ + + + + +
- - +
+ ); }} diff --git a/src/components/Settings/Notifications/NotificationsPushover/index.tsx b/src/components/Settings/Notifications/NotificationsPushover/index.tsx index d16559fda..058be3e6a 100644 --- a/src/components/Settings/Notifications/NotificationsPushover/index.tsx +++ b/src/components/Settings/Notifications/NotificationsPushover/index.tsx @@ -1,32 +1,36 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; import globalMessages from '../../../../i18n/globalMessages'; -import Alert from '../../../Common/Alert'; import Button from '../../../Common/Button'; import LoadingSpinner from '../../../Common/LoadingSpinner'; import NotificationTypeSelector from '../../../NotificationTypeSelector'; const messages = defineMessages({ agentenabled: 'Enable Agent', - accessToken: 'Application/API Token', + accessToken: 'Application API Token', + accessTokenTip: + 'Register an application for use with Overseerr', userToken: 'User or Group Key', + userTokenTip: + 'Your 30-character user or group identifier', validationAccessTokenRequired: 'You must provide a valid application token', validationUserTokenRequired: 'You must provide a valid user key', pushoversettingssaved: 'Pushover notification settings saved successfully!', pushoversettingsfailed: 'Pushover notification settings failed to save.', - testsent: 'Pushover test notification sent!', - settinguppushoverDescription: - 'To configure Pushover notifications, you will need to register an application and enter the API token below. (You can use one of the official Overseerr icons on GitHub.)', + toastPushoverTestSending: 'Sending Pushover test notification…', + toastPushoverTestSuccess: 'Pushover test notification sent!', + toastPushoverTestFailed: 'Pushover test notification failed to send.', }); const NotificationsPushover: React.FC = () => { const intl = useIntl(); - const { addToast } = useToasts(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/pushover' ); @@ -97,133 +101,155 @@ const NotificationsPushover: React.FC = () => { > {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => { const testSettings = async () => { - await axios.post('/api/v1/settings/notifications/pushover/test', { - enabled: true, - types: values.types, - options: { - accessToken: values.accessToken, - userToken: values.userToken, - }, - }); + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastPushoverTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/pushover/test', { + enabled: true, + types: values.types, + options: { + accessToken: values.accessToken, + userToken: values.userToken, + }, + }); - addToast(intl.formatMessage(messages.testsent), { - appearance: 'info', - autoDismiss: true, - }); + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPushoverTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPushoverTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } }; return ( - <> - - {msg} - - ); - }, - IconLink: function IconLink(msg) { - return ( - - {msg} - - ); - }, - })} - type="info" - /> -
-
- -
- -
+ +
+ +
+
-
- -
-
- -
- {errors.accessToken && touched.accessToken && ( -
{errors.accessToken}
- )} +
+
+ +
+
+
+ {errors.accessToken && touched.accessToken && ( +
{errors.accessToken}
+ )}
-
- -
-
- -
- {errors.userToken && touched.userToken && ( -
{errors.userToken}
- )} +
+
+ +
+
+
+ {errors.userToken && touched.userToken && ( +
{errors.userToken}
+ )}
- setFieldValue('types', newTypes)} - /> -
-
- - - - - - -
+
+ setFieldValue('types', newTypes)} + /> +
+
+ + + + + +
- - +
+ ); }} diff --git a/src/components/Settings/Notifications/NotificationsSlack/index.tsx b/src/components/Settings/Notifications/NotificationsSlack/index.tsx index e71143c06..57a7361d6 100644 --- a/src/components/Settings/Notifications/NotificationsSlack/index.tsx +++ b/src/components/Settings/Notifications/NotificationsSlack/index.tsx @@ -1,12 +1,11 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; import globalMessages from '../../../../i18n/globalMessages'; -import Alert from '../../../Common/Alert'; import Button from '../../../Common/Button'; import LoadingSpinner from '../../../Common/LoadingSpinner'; import NotificationTypeSelector from '../../../NotificationTypeSelector'; @@ -14,17 +13,20 @@ import NotificationTypeSelector from '../../../NotificationTypeSelector'; const messages = defineMessages({ agentenabled: 'Enable Agent', webhookUrl: 'Webhook URL', + webhookUrlTip: + 'Create an Incoming Webhook integration', slacksettingssaved: 'Slack notification settings saved successfully!', slacksettingsfailed: 'Slack notification settings failed to save.', - testsent: 'Slack test notification sent!', - settingupslackDescription: - 'To configure Slack notifications, you will need to create an Incoming Webhook integration and enter the webhook URL below.', + toastSlackTestSending: 'Sending Slack test notification…', + toastSlackTestSuccess: 'Slack test notification sent!', + toastSlackTestFailed: 'Slack test notification failed to send.', validationWebhookUrl: 'You must provide a valid URL', }); const NotificationsSlack: React.FC = () => { const intl = useIntl(); - const { addToast } = useToasts(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/slack' ); @@ -46,138 +48,162 @@ const NotificationsSlack: React.FC = () => { } return ( - <> - - {msg} - - ); - }, - })} - type="info" - /> - { + { + try { + await axios.post('/api/v1/settings/notifications/slack', { + enabled: values.enabled, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + }, + }); + addToast(intl.formatMessage(messages.slacksettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.slacksettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => { + const testSettings = async () => { + setIsTesting(true); + let toastId: string | undefined; try { - await axios.post('/api/v1/settings/notifications/slack', { - enabled: values.enabled, + addToast( + intl.formatMessage(messages.toastSlackTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/slack/test', { + enabled: true, types: values.types, options: { webhookUrl: values.webhookUrl, }, }); - addToast(intl.formatMessage(messages.slacksettingssaved), { - appearance: 'success', + + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastSlackTestSuccess), { autoDismiss: true, + appearance: 'success', }); } catch (e) { - addToast(intl.formatMessage(messages.slacksettingsfailed), { - appearance: 'error', + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastSlackTestFailed), { autoDismiss: true, + appearance: 'error', }); } finally { - revalidate(); + setIsTesting(false); } - }} - > - {({ - errors, - touched, - isSubmitting, - values, - isValid, - setFieldValue, - }) => { - const testSettings = async () => { - await axios.post('/api/v1/settings/notifications/slack/test', { - enabled: true, - types: values.types, - options: { - webhookUrl: values.webhookUrl, - }, - }); + }; - addToast(intl.formatMessage(messages.testsent), { - appearance: 'info', - autoDismiss: true, - }); - }; - - return ( -
-
- -
- -
+ return ( + +
+ +
+
-
- -
-
- -
- {errors.webhookUrl && touched.webhookUrl && ( -
{errors.webhookUrl}
- )} +
+
+ +
+
+
+ {errors.webhookUrl && touched.webhookUrl && ( +
{errors.webhookUrl}
+ )}
- setFieldValue('types', newTypes)} - /> -
-
- - - - - - -
+
+ setFieldValue('types', newTypes)} + /> +
+
+ + + + + +
- - ); - }} - - +
+ + ); + }} + ); }; diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx index 6f8c230be..30e8f4165 100644 --- a/src/components/Settings/Notifications/NotificationsTelegram.tsx +++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx @@ -1,37 +1,42 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; import globalMessages from '../../../i18n/globalMessages'; -import Alert from '../../Common/Alert'; import Button from '../../Common/Button'; import LoadingSpinner from '../../Common/LoadingSpinner'; +import SensitiveInput from '../../Common/SensitiveInput'; import NotificationTypeSelector from '../../NotificationTypeSelector'; const messages = defineMessages({ agentenabled: 'Enable Agent', botUsername: 'Bot Username', botUsernameTip: - 'Allow users to start a chat with the bot and configure their own personal notifications', - botAPI: 'Bot Authentication Token', + 'Allow users to also start a chat with your bot and configure their own notifications', + botAPI: 'Bot Authorization Token', + botApiTip: + 'Create a bot for use with Overseerr', chatId: 'Chat ID', - validationBotAPIRequired: 'You must provide a bot authentication token', + chatIdTip: + 'Start a chat with your bot, add @get_id_bot, and issue the /my_id command', + validationBotAPIRequired: 'You must provide a bot authorization token', validationChatIdRequired: 'You must provide a valid chat ID', telegramsettingssaved: 'Telegram notification settings saved successfully!', telegramsettingsfailed: 'Telegram notification settings failed to save.', - telegramtestsent: 'Telegram test notification sent!', - settinguptelegramDescription: - 'To configure Telegram notifications, you will need to create a bot and get the bot API key. Additionally, you will need the chat ID for the chat to which you would like to send notifications. You can find this by adding @get_id_bot to the chat and issuing the /my_id command.', + toastTelegramTestSending: 'Sending Telegram test notification…', + toastTelegramTestSuccess: 'Telegram test notification sent!', + toastTelegramTestFailed: 'Telegram test notification failed to send.', sendSilently: 'Send Silently', sendSilentlyTip: 'Send notifications with no sound', }); const NotificationsTelegram: React.FC = () => { const intl = useIntl(); - const { addToast } = useToasts(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/telegram' ); @@ -102,174 +107,186 @@ const NotificationsTelegram: React.FC = () => { > {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => { const testSettings = async () => { - await axios.post('/api/v1/settings/notifications/telegram/test', { - enabled: true, - types: values.types, - options: { - botAPI: values.botAPI, - chatId: values.chatId, - sendSilently: values.sendSilently, - botUsername: values.botUsername, - }, - }); + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastTelegramTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/telegram/test', { + enabled: true, + types: values.types, + options: { + botAPI: values.botAPI, + chatId: values.chatId, + sendSilently: values.sendSilently, + botUsername: values.botUsername, + }, + }); - addToast(intl.formatMessage(messages.telegramtestsent), { - appearance: 'info', - autoDismiss: true, - }); + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastTelegramTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastTelegramTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } }; return ( - <> - - {msg} - - ); - }, - GetIdBotLink: function GetIdBotLink(msg) { - return ( - - {msg} - - ); - }, - code: function code(msg) { - return {msg}; - }, - })} - type="info" - /> -
-
- -
- -
+ +
+ +
+
-
- -
-
- -
- {errors.botUsername && touched.botUsername && ( -
{errors.botUsername}
- )} +
+
+ +
+
+
+ {errors.botAPI && touched.botAPI && ( +
{errors.botAPI}
+ )}
-
- -
-
- -
- {errors.botAPI && touched.botAPI && ( -
{errors.botAPI}
- )} +
+
+ +
+
+
+ {errors.botUsername && touched.botUsername && ( +
{errors.botUsername}
+ )}
-
- -
-
- -
- {errors.chatId && touched.chatId && ( -
{errors.chatId}
- )} +
+
+ +
+
+
+ {errors.chatId && touched.chatId && ( +
{errors.chatId}
+ )}
-
- -
- -
+
+
+ +
+
- setFieldValue('types', newTypes)} - /> -
-
- - - - - - -
+
+ setFieldValue('types', newTypes)} + /> +
+
+ + + + + +
- - +
+ ); }} diff --git a/src/components/Settings/Notifications/NotificationsWebPush/index.tsx b/src/components/Settings/Notifications/NotificationsWebPush/index.tsx new file mode 100644 index 000000000..aa4f98bf2 --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsWebPush/index.tsx @@ -0,0 +1,155 @@ +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR, { mutate } from 'swr'; +import globalMessages from '../../../../i18n/globalMessages'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import NotificationTypeSelector from '../../../NotificationTypeSelector'; + +const messages = defineMessages({ + agentenabled: 'Enable Agent', + webpushsettingssaved: 'Web push notification settings saved successfully!', + webpushsettingsfailed: 'Web push notification settings failed to save.', + toastWebPushTestSending: 'Sending web push test notification…', + toastWebPushTestSuccess: 'Web push test notification sent!', + toastWebPushTestFailed: 'Web push test notification failed to send.', +}); + +const NotificationsWebPush: React.FC = () => { + const intl = useIntl(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/notifications/webpush' + ); + + if (!data && !error) { + return ; + } + + return ( + <> + { + try { + await axios.post('/api/v1/settings/notifications/webpush', { + enabled: values.enabled, + types: values.types, + options: {}, + }); + mutate('/api/v1/settings/public'); + addToast(intl.formatMessage(messages.webpushsettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.webpushsettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ isSubmitting, values, isValid, setFieldValue }) => { + const testSettings = async () => { + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastWebPushTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/webpush/test', { + enabled: true, + types: values.types, + options: {}, + }); + + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastWebPushTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastWebPushTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } + }; + + return ( +
+
+ +
+ +
+
+ setFieldValue('types', newTypes)} + /> +
+
+ + + + + + +
+
+ + ); + }} +
+ + ); +}; + +export default NotificationsWebPush; diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx index 8b6dffad7..20727e850 100644 --- a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -2,7 +2,8 @@ import { QuestionMarkCircleIcon, RefreshIcon } from '@heroicons/react/solid'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import dynamic from 'next/dynamic'; -import React from 'react'; +import Link from 'next/link'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -46,7 +47,9 @@ const messages = defineMessages({ validationJsonPayloadRequired: 'You must provide a valid JSON payload', webhooksettingssaved: 'Webhook notification settings saved successfully!', webhooksettingsfailed: 'Webhook notification settings failed to save.', - testsent: 'Webhook test notification sent!', + toastWebhookTestSending: 'Sending webhook test notification…', + toastWebhookTestSuccess: 'Webhook test notification sent!', + toastWebhookTestFailed: 'Webhook test notification failed to send.', resetPayload: 'Reset to Default', resetPayloadSuccess: 'JSON payload reset successfully!', customJson: 'JSON Payload', @@ -56,7 +59,8 @@ const messages = defineMessages({ const NotificationsWebhook: React.FC = () => { const intl = useIntl(); - const { addToast } = useToasts(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/webhook' ); @@ -157,20 +161,47 @@ const NotificationsWebhook: React.FC = () => { }; const testSettings = async () => { - await axios.post('/api/v1/settings/notifications/webhook/test', { - enabled: true, - types: values.types, - options: { - webhookUrl: values.webhookUrl, - jsonPayload: JSON.stringify(values.jsonPayload), - authHeader: values.authHeader, - }, - }); + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastWebhookTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/webhook/test', { + enabled: true, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + jsonPayload: JSON.stringify(values.jsonPayload), + authHeader: values.authHeader, + }, + }); - addToast(intl.formatMessage(messages.testsent), { - appearance: 'info', - autoDismiss: true, - }); + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastWebhookTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastWebhookTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } }; return ( @@ -178,19 +209,25 @@ const NotificationsWebhook: React.FC = () => {
-
-
-
@@ -257,21 +301,22 @@ const NotificationsWebhook: React.FC = () => {
diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 992e3ac47..a621228b2 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -181,13 +181,13 @@ const SettingsJobs: React.FC = () => { {job.running ? ( ) : ( )} @@ -226,8 +226,8 @@ const SettingsJobs: React.FC = () => { {formatBytes(cache.stats.vsize)} diff --git a/src/components/Settings/SettingsLogs/index.tsx b/src/components/Settings/SettingsLogs/index.tsx index a4aaf755e..e244a2de9 100644 --- a/src/components/Settings/SettingsLogs/index.tsx +++ b/src/components/Settings/SettingsLogs/index.tsx @@ -142,7 +142,7 @@ const SettingsLogs: React.FC = () => { > } + iconSvg={} onCancel={() => setActiveLog(null)} cancelText={intl.formatMessage(globalMessages.close)} onOk={() => (activeLog ? copyLogString(activeLog) : undefined)} @@ -243,13 +243,7 @@ const SettingsLogs: React.FC = () => { buttonType={refreshInterval ? 'default' : 'primary'} onClick={() => toggleLogs()} > - - {refreshInterval ? ( - - ) : ( - - )} - + {refreshInterval ? : } {intl.formatMessage( refreshInterval ? messages.pauseLogs : messages.resumeLogs @@ -335,7 +329,7 @@ const SettingsLogs: React.FC = () => { onClick={() => setActiveLog(row)} className="mr-2" > - + )} diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index d3a669317..1f1ed4c9d 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -1,18 +1,25 @@ import { RefreshIcon } from '@heroicons/react/solid'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; -import React, { useMemo } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; import * as Yup from 'yup'; -import type { Language, MainSettings } from '../../../server/lib/settings'; +import { UserSettingsGeneralResponse } from '../../../server/interfaces/api/userSettingsInterfaces'; +import type { MainSettings } from '../../../server/lib/settings'; +import { + availableLanguages, + AvailableLocales, +} from '../../context/LanguageContext'; +import useLocale from '../../hooks/useLocale'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; import Badge from '../Common/Badge'; import Button from '../Common/Button'; import LoadingSpinner from '../Common/LoadingSpinner'; import PageTitle from '../Common/PageTitle'; +import SensitiveInput from '../Common/SensitiveInput'; import LanguageSelector from '../LanguageSelector'; import RegionSelector from '../RegionSelector'; import CopyButton from './CopyButton'; @@ -49,18 +56,21 @@ const messages = defineMessages({ validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', + locale: 'Display Language', }); const SettingsMain: React.FC = () => { const { addToast } = useToasts(); - const { hasPermission: userHasPermission } = useUser(); + const { user: currentUser, hasPermission: userHasPermission } = useUser(); const intl = useIntl(); + const { setLocale } = useLocale(); const { data, error, revalidate } = useSWR( '/api/v1/settings/main' ); - const { data: languages, error: languagesError } = useSWR( - '/api/v1/languages' + const { data: userData } = useSWR( + currentUser ? `/api/v1/user/${currentUser.id}/settings/main` : null ); + const MainSettingsSchema = Yup.object().shape({ applicationTitle: Yup.string().required( intl.formatMessage(messages.validationApplicationTitle) @@ -96,26 +106,7 @@ const SettingsMain: React.FC = () => { } }; - const sortedLanguages = useMemo( - () => - languages?.sort((lang1, lang2) => { - const lang1Name = - intl.formatDisplayName(lang1.iso_639_1, { - type: 'language', - fallback: 'none', - }) ?? lang1.english_name; - const lang2Name = - intl.formatDisplayName(lang2.iso_639_1, { - type: 'language', - fallback: 'none', - }) ?? lang2.english_name; - - return lang1Name === lang2Name ? 0 : lang1Name > lang2Name ? 1 : -1; - }), - [intl, languages] - ); - - if (!data && !error && !languages && !languagesError) { + if (!data && !error) { return ; } @@ -142,6 +133,7 @@ const SettingsMain: React.FC = () => { applicationUrl: data?.applicationUrl, csrfProtection: data?.csrfProtection, hideAvailable: data?.hideAvailable, + locale: data?.locale ?? 'en', region: data?.region, originalLanguage: data?.originalLanguage, partialRequestsEnabled: data?.partialRequestsEnabled, @@ -156,6 +148,7 @@ const SettingsMain: React.FC = () => { applicationUrl: values.applicationUrl, csrfProtection: values.csrfProtection, hideAvailable: values.hideAvailable, + locale: values.locale, region: values.region, originalLanguage: values.originalLanguage, partialRequestsEnabled: values.partialRequestsEnabled, @@ -163,6 +156,14 @@ const SettingsMain: React.FC = () => { }); mutate('/api/v1/settings/public'); + if (setLocale) { + setLocale( + (userData?.locale + ? userData.locale + : values.locale) as AvailableLocales + ); + } + addToast(intl.formatMessage(messages.toastSettingsSuccess), { autoDismiss: true, appearance: 'success', @@ -187,7 +188,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-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" + className="input-action" > - +
@@ -221,7 +222,6 @@ const SettingsMain: React.FC = () => { id="applicationTitle" name="applicationTitle" type="text" - placeholder="Overseerr" />
{errors.applicationTitle && touched.applicationTitle && ( @@ -239,7 +239,7 @@ const SettingsMain: React.FC = () => { id="applicationUrl" name="applicationUrl" type="text" - placeholder="https://os.example.com" + inputMode="url" />
{errors.applicationUrl && touched.applicationUrl && ( @@ -291,6 +291,28 @@ const SettingsMain: React.FC = () => { />
+
+ +
+
+ + {(Object.keys( + availableLanguages + ) as (keyof typeof availableLanguages)[]).map((key) => ( + + ))} + +
+
+
- +
+ +
@@ -316,7 +340,6 @@ const SettingsMain: React.FC = () => {
diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 3c73c001b..a82436cd7 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -1,8 +1,9 @@ import { AtSymbolIcon } from '@heroicons/react/outline'; -import { LightningBoltIcon } from '@heroicons/react/solid'; +import { CloudIcon, LightningBoltIcon } from '@heroicons/react/solid'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import DiscordLogo from '../../assets/extlogos/discord.svg'; +import LunaSeaLogo from '../../assets/extlogos/lunasea.svg'; import PushbulletLogo from '../../assets/extlogos/pushbullet.svg'; import PushoverLogo from '../../assets/extlogos/pushover.svg'; import SlackLogo from '../../assets/extlogos/slack.svg'; @@ -18,6 +19,7 @@ const messages = defineMessages({ 'Configure and enable notification agents.', email: 'Email', webhook: 'Webhook', + webpush: 'Web Push', }); const SettingsNotifications: React.FC = ({ children }) => { @@ -46,6 +48,17 @@ const SettingsNotifications: React.FC = ({ children }) => { route: '/settings/notifications/discord', regex: /^\/settings\/notifications\/discord/, }, + { + text: 'LunaSea', + content: ( + + + LunaSea + + ), + route: '/settings/notifications/lunasea', + regex: /^\/settings\/notifications\/lunasea/, + }, { text: 'Pushbullet', content: ( @@ -90,6 +103,17 @@ const SettingsNotifications: React.FC = ({ children }) => { route: '/settings/notifications/telegram', regex: /^\/settings\/notifications\/telegram/, }, + { + text: intl.formatMessage(messages.webpush), + content: ( + + + {intl.formatMessage(messages.webpush)} + + ), + route: '/settings/notifications/webpush', + regex: /^\/settings\/notifications\/webpush/, + }, { text: intl.formatMessage(messages.webhook), content: ( diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 59ba75af4..b584247e5 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -21,12 +21,10 @@ const messages = defineMessages({ plex: 'Plex', plexsettings: 'Plex Settings', plexsettingsDescription: - 'Configure the settings for your Plex server. Overseerr scans your Plex libraries to see what content is available.', + 'Configure the settings for your Plex server. Overseerr scans your Plex libraries to determine content availability.', servername: 'Server Name', servernameTip: 'Automatically retrieved from Plex after saving', - servernamePlaceholder: 'Plex Server Name', serverpreset: 'Server', - serverpresetPlaceholder: 'Plex Server', serverLocal: 'local', serverRemote: 'remote', serverSecure: 'secure', @@ -40,11 +38,10 @@ const messages = defineMessages({ toastPlexConnectingSuccess: 'Plex connection established successfully!', toastPlexConnectingFailure: 'Failed to connect to Plex.', settingUpPlexDescription: - 'To set up Plex, you can either enter your details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.', + 'To set up Plex, you can either enter the details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.', hostname: 'Hostname or IP Address', port: 'Port', enablessl: 'Enable SSL', - timeout: 'Timeout', plexlibraries: 'Plex Libraries', plexlibrariesDescription: 'The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.', @@ -58,7 +55,7 @@ const messages = defineMessages({ librariesRemaining: 'Libraries Remaining: {count}', startscan: 'Start Scan', cancelscan: 'Cancel Scan', - validationHostnameRequired: 'You must provide a hostname or IP address', + validationHostnameRequired: 'You must provide a valid hostname or IP address', validationPortRequired: 'You must provide a valid port number', }); @@ -282,7 +279,7 @@ const SettingsPlex: React.FC = ({ onComplete }) => { = ({ onComplete }) => { id="name" name="name" className="cursor-not-allowed" - placeholder={intl.formatMessage( - messages.servernamePlaceholder - )} value={data?.name} readOnly /> @@ -373,9 +367,6 @@ const SettingsPlex: React.FC = ({ onComplete }) => {