Merge branch 'develop'

pull/864/head
sct 4 years ago
commit 12c5bbd778

@ -292,6 +292,15 @@
"contributions": [
"code"
]
},
{
"login": "douglasparker",
"name": "Douglas Parker",
"avatar_url": "https://avatars.githubusercontent.com/u/18235822?v=4",
"profile": "https://www.douglas-parker.com",
"contributions": [
"doc"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

@ -1,15 +1,23 @@
node_modules
.next
**/*.md
**/.gitkeep
**/.vscode
.all-contributorsrc
.dockerignore
.editorconfig
.eslintrc.js
.git
.gitbook.yaml
.gitconfig
.gitignore
.github
.all-contributorsrc
.editorconfig
.next
.prettierignore
**/README.md
**/.vscode
config/db/db.sqlite3
config/db/logs/overseerr.log
Dockerfil**
**.md
Dockerfile*
docker-compose.yml
docs
LICENSE
node_modules
snap
stylelint.config.js

@ -6,3 +6,9 @@ updates:
interval: daily
time: '20:00'
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: '/'
schedule:
interval: daily
time: '20:00'
open-pull-requests-limit: 10

@ -0,0 +1,23 @@
name: Deploy API Docs
on:
push:
branches:
- develop
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Generate Swagger UI
uses: Legion2/swagger-ui-action@v1
with:
output: swagger-ui
spec-file: overseerr-api.yml
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: swagger-ui
cname: api-docs.overseerr.dev

@ -0,0 +1,9 @@
{
"formats": {
"openapi": {
"rootDir": ".",
"include": ["**"]
}
},
"exclude": ["docs"]
}

@ -1,4 +1,4 @@
FROM node:12.18-alpine AS BUILD_IMAGE
FROM node:14.15-alpine AS BUILD_IMAGE
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
@ -11,24 +11,22 @@ RUN yarn --frozen-lockfile && \
# remove development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
RUN yarn cache clean
FROM node:12.18-alpine
RUN rm -rf src && \
rm -rf server
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
RUN touch config/DOCKER
RUN apk add tzdata
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
COPY . /app
WORKDIR /app
# copy from build image
COPY --from=BUILD_IMAGE /app/dist ./dist
COPY --from=BUILD_IMAGE /app/.next ./.next
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
FROM node:14.15-alpine
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
RUN apk add --no-cache tzdata
# copy from build image
COPY --from=BUILD_IMAGE /app /app
WORKDIR /app
CMD yarn start

@ -16,21 +16,21 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-31-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-32-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **Sonarr**, **Radarr** and **Plex**!
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)** and **[Plex](https://www.plex.tv/)**!
## Current Features
- Full Plex integration. Login and manage user access with Plex!
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
- Syncs to your Plex library to know what titles you already have.
- Easy integration with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
- Plex libraries sync to know what titles you already have.
- Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests.
- Granular permission system
- Mobile friendly design, for when you need to approve requests on the go!
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Mobile-friendly design, for when you need to approve requests on the go!
## In Development
@ -46,19 +46,18 @@
## Getting Started
Check out our documentation for steps on how to install and run Overseerr:
Check out our documentation for instructions on how to install and run Overseerr:
https://docs.overseerr.dev/getting-started/installation
## Running Overseerr
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
Currently, Overseerr is primarily distributed as Docker images. If you have Docker, you can run Overseerr with:
```
docker run -d \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-e PROXY=<yes|no>
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
@ -67,7 +66,7 @@ docker run -d \
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps.
⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **sctx/overseerr:develop** instead! ⚠️
⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️
## Preview
@ -78,11 +77,13 @@ After running Overseerr for the first time, configure it by visiting the web UI
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues).
## API Documentation
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
Our documentation is built on every commit and hosted at https://api-docs.overseerr.dev
Also, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
## Community
@ -144,6 +145,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
<td align="center"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
</tr>
</table>

@ -115,7 +115,7 @@ server {
# HTTP Strict Transport Security
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
# Prevent some categories of XSS attacks (X-XSS-Protection)
add_header X-XSS-Protection "1; mode=block" always;
# Provide clickjacking protection (X-Frame-Options)

@ -2,8 +2,43 @@ openapi: '3.0.2'
info:
title: 'Overseerr API'
version: '1.0.0'
description: |
This is the documentation for the Overseerr API backend.
Two primary authentication methods are supported:
- **Cookie Authentication**: A valid login to the `/auth/login` or `/auth/local` will generate a valid authentication cookie.
- **API Key Authentication**: Login is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Overseerr.
tags:
- name: public
description: Public API endpoints requiring no authentication.
- name: settings
description: Endpoints related to Overseerr's settings and configuration.
- name: auth
description: Endpoints related to logging in or out, and the currently authenticated user.
- name: users
description: Endpoints related to user management.
- name: search
description: Endpoints related to search and discovery.
- name: request
description: Endpoints related to request management.
- name: movies
description: Endpoints related to retrieving movies and their details.
- name: tv
description: Endpoints related to retrieving TV series and their details.
- name: person
description: Endpoints related to retrieving Person details.
- name: media
description: Endpoints related to media management.
- name: collection
description: Endpoints related to retrieving Collection details.
- name: service
description: Endpoinst related to getting Service (Radarr/Sonarr) details.
servers:
- url: /api/v1
- url: '{server}/api/v1'
variables:
server:
default: http://localhost:5055
components:
schemas:
@ -59,6 +94,9 @@ components:
type: string
example: 'anapikey'
readOnly: true
applicationTitle:
type: string
example: Overseerr
applicationUrl:
type: string
example: https://os.example.com
@ -71,6 +109,9 @@ components:
hideAvailable:
type: boolean
example: false
localLogin:
type: boolean
example: true
defaultPermissions:
type: number
example: 32
@ -116,17 +157,6 @@ components:
- machineId
- ip
- port
PlexStatus:
type: object
properties:
settings:
$ref: '#/components/schemas/PlexSettings'
status:
type: number
example: 200
message:
type: string
example: 'OK'
PlexConnection:
type: object
properties:
@ -391,29 +421,6 @@ components:
initialized:
type: boolean
example: false
AllSettings:
type: object
properties:
main:
$ref: '#/components/schemas/MainSettings'
plex:
$ref: '#/components/schemas/PlexSettings'
radarr:
type: array
items:
$ref: '#/components/schemas/RadarrSettings'
sonarr:
type: array
items:
$ref: '#/components/schemas/SonarrSettings'
public:
$ref: '#/components/schemas/PublicSettings'
required:
- main
- plex
- radarr
- sonarr
- public
MovieResult:
type: object
required:
@ -587,7 +594,7 @@ components:
readOnly: true
imdbId:
type: string
example: 123
example: 'tt123'
adult:
type: boolean
backdropPath:
@ -1470,6 +1477,27 @@ paths:
example: 1.0.0
commitTag:
type: string
/status/appdata:
get:
summary: Get application data volume status
description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs.
security: []
tags:
- public
responses:
'200':
description: Application data volume status and path
content:
application/json:
schema:
type: object
properties:
appData:
type: boolean
example: true
appDataPath:
type: string
example: /app/config
/settings/main:
get:
summary: Get main settings
@ -2024,6 +2052,56 @@ paths:
running:
type: boolean
example: false
/settings/cache:
get:
summary: Get a list of active caches
description: Retrieves a list of all active caches and their current stats.
tags:
- settings
responses:
'200':
description: Caches returned
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: string
example: cache-id
name:
type: string
example: cache name
stats:
type: object
properties:
hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
/settings/cache/{cacheId}/flush:
get:
summary: Flush a specific cache
description: Flushes all data from the cache ID provided
tags:
- settings
parameters:
- in: path
name: cacheId
required: true
schema:
type: string
responses:
'204':
description: 'Flushed cache'
/settings/notifications:
get:
summary: Return notification settings
@ -2504,7 +2582,8 @@ paths:
application/json:
schema:
type: array
$ref: '#/components/schemas/User'
items:
$ref: '#/components/schemas/User'
/user/import-from-plex:
post:
@ -2971,7 +3050,7 @@ paths:
name: requestId
description: Request ID
required: true
example: 1
example: '1'
schema:
type: string
responses:
@ -2991,7 +3070,7 @@ paths:
name: requestId
description: Request ID
required: true
example: 1
example: '1'
schema:
type: string
responses:
@ -3011,7 +3090,7 @@ paths:
name: requestId
description: Request ID
required: true
example: 1
example: '1'
schema:
type: string
responses:
@ -3033,7 +3112,7 @@ paths:
required: true
schema:
type: string
example: 1
example: '1'
responses:
'200':
description: Retry triggered
@ -3057,7 +3136,7 @@ paths:
required: true
schema:
type: string
example: 1
example: '1'
- in: path
name: status
description: New status
@ -3529,7 +3608,7 @@ paths:
name: mediaId
description: Media ID
required: true
example: 1
example: '1'
schema:
type: string
responses:
@ -3546,7 +3625,7 @@ paths:
name: mediaId
description: Media ID
required: true
example: 1
example: '1'
schema:
type: string
- in: path

@ -17,6 +17,7 @@
},
"license": "MIT",
"dependencies": {
"@headlessui/react": "^0.2.0-da179ca",
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
"ace-builds": "^1.4.12",
@ -29,24 +30,25 @@
"csurf": "^1.11.0",
"email-templates": "^8.0.3",
"express": "^4.17.1",
"express-openapi-validator": "^4.10.8",
"express-openapi-validator": "^4.10.11",
"express-session": "^1.17.1",
"formik": "^2.2.6",
"gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.20",
"next": "10.0.3",
"node-schedule": "^1.3.2",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
"nodemailer": "^6.4.17",
"nookies": "^2.5.2",
"plex-api": "^5.3.1",
"pug": "^3.0.0",
"react": "17.0.1",
"react-ace": "^9.2.1",
"react-ace": "^9.3.0",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.1",
"react-intersection-observer": "^8.31.0",
"react-intl": "^5.10.16",
"react-intl": "^5.12.0",
"react-markdown": "^5.0.3",
"react-spring": "^8.0.27",
"react-toast-notifications": "^2.4.0",
@ -57,7 +59,7 @@
"secure-random-password": "^0.2.2",
"sqlite3": "^5.0.0",
"swagger-ui-express": "^4.1.6",
"swr": "^0.4.0",
"swr": "^0.4.1",
"typeorm": "^0.2.30",
"uuid": "^8.3.2",
"winston": "^3.3.3",
@ -67,7 +69,7 @@
"yup": "^0.32.8"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/cli": "^7.12.13",
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@semantic-release/changelog": "^5.0.1",
@ -81,14 +83,14 @@
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/csurf": "^1.11.0",
"@types/email-templates": "^8.0.0",
"@types/email-templates": "^8.0.1",
"@types/express": "^4.17.11",
"@types/express-session": "^1.17.0",
"@types/express-session": "^1.17.3",
"@types/lodash": "^4.14.168",
"@types/node": "^14.14.22",
"@types/node": "^14.14.24",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^17.0.0",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@types/react-toast-notifications": "^2.4.0",
"@types/react-transition-group": "^4.4.0",
@ -98,17 +100,17 @@
"@types/xml2js": "^0.4.8",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"autoprefixer": "^9",
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"autoprefixer": "^10.2.4",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.3",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.18.0",
"eslint": "^7.19.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-formatjs": "^2.10.3",
"eslint-plugin-formatjs": "^2.12.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
@ -117,10 +119,10 @@
"husky": "^4.3.8",
"lint-staged": "^10.5.3",
"nodemon": "^2.0.7",
"postcss": "^7",
"postcss": "^8.2.4",
"postcss-preset-env": "^6.7.0",
"prettier": "^2.2.1",
"semantic-release": "^17.3.6",
"semantic-release": "^17.3.7",
"semantic-release-docker": "^2.2.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
"ts-node": "^9.1.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1024 1025" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="1024" height="1024"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="#24292E" fill-opacity="0" xlink:href="#a"/><g transform="translate(70 18)"><path d="m105.3 156.12l7.522 719.97c-60.173 7.579-105.3-22.736-105.3-83.364l-7.5216-598.71c0-189.46 173-234.94 278.3-159.15l534.03 310.72c75.216 53.05 90.259 151.57 52.651 219.78-7.522-53.05-30.086-83.365-75.216-113.68l-601.73-341.04c-45.13-30.315-82.738-22.736-82.738 45.471z" fill="#fff"/><path transform="translate(60.173 535.05)" d="m0 378.93c45.13 15.158 90.259 7.579 127.87-15.157l616.77-363.77c37.607 53.05 30.086 106.1-15.044 136.42l-518.99 303.14c-75.216 37.893-173 0-210.6-60.629z" fill="#fff"/><path transform="translate(240.69 284.95)" d="M0 416.822L368.558 204.622L7.52159 0L0 416.822Z" fill="#FFC230"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

@ -0,0 +1 @@
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/> <path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/> <g clip-rule="evenodd"> <path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/> <path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/> <path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/> </g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -8,7 +8,9 @@ const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapp
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
const MAPPING_URL =
'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml';
const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml');
const LOCAL_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/anime-list.xml`
: path.join(__dirname, '../../config/anime-list.xml');
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);

@ -0,0 +1,102 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
}
class ExternalAPI {
protected axios: AxiosInstance;
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, unknown>,
options: ExternalAPIOptions = {}
) {
this.axios = axios.create({
baseURL: baseUrl,
params,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
this.baseUrl = baseUrl;
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
const keyTtl = this.cache?.getTtl(cacheKey) ?? 0;
// If the item has passed our rolling check, fetch again in background
if (
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
private serializeCacheKey(
endpoint: string,
params?: Record<string, unknown>
) {
if (!params) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
}
}
export default ExternalAPI;

@ -118,7 +118,7 @@ class PlexAPI {
options: {
identifier: settings.clientId,
product: 'Overseerr',
deviceName: 'Overseerr',
deviceName: settings.main.applicationTitle,
platform: 'Overseerr',
},
});

@ -1,6 +1,7 @@
import Axios, { AxiosInstance } from 'axios';
import cacheManager from '../lib/cache';
import { RadarrSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface RadarrMovieOptions {
title: string;
@ -73,21 +74,23 @@ interface QueueResponse {
records: QueueItem[];
}
class RadarrAPI {
class RadarrAPI extends ExternalAPI {
static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
return `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
}
private axios: AxiosInstance;
constructor({ url, apiKey }: { url: string; apiKey: string }) {
this.axios = Axios.create({
baseURL: url,
params: {
super(
url,
{
apikey: apiKey,
},
});
{
nodeCache: cacheManager.getCache('radarr').data,
}
);
}
public getMovies = async (): Promise<RadarrMovie[]> => {
@ -238,9 +241,13 @@ class RadarrAPI {
public getProfiles = async (): Promise<RadarrProfile[]> => {
try {
const response = await this.axios.get<RadarrProfile[]>(`/profile`);
const data = await this.getRolling<RadarrProfile[]>(
`/profile`,
undefined,
3600
);
return response.data;
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
}
@ -248,9 +255,13 @@ class RadarrAPI {
public getRootFolders = async (): Promise<RadarrRootFolder[]> => {
try {
const response = await this.axios.get<RadarrRootFolder[]>(`/rootfolder`);
const data = await this.getRolling<RadarrRootFolder[]>(
`/rootfolder`,
undefined,
3600
);
return response.data;
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
}

@ -1,4 +1,5 @@
import axios, { AxiosInstance } from 'axios';
import cacheManager from '../lib/cache';
import ExternalAPI from './externalapi';
interface RTMovieOldSearchResult {
id: number;
@ -55,17 +56,19 @@ export interface RTRating {
* Unfortunately, we need to do it by searching for the movie name, so it's
* not always accurate.
*/
class RottenTomatoes {
private axios: AxiosInstance;
class RottenTomatoes extends ExternalAPI {
constructor() {
this.axios = axios.create({
baseURL: 'https://www.rottentomatoes.com/api/private',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
super(
'https://www.rottentomatoes.com/api/private',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('rt').data,
}
);
}
/**
@ -85,33 +88,30 @@ class RottenTomatoes {
year: number
): Promise<RTRating | null> {
try {
const response = await this.axios.get<RTMovieSearchResponse>(
'/v1.0/movies',
{
params: { q: name },
}
);
const data = await this.get<RTMovieSearchResponse>('/v1.0/movies', {
params: { q: name },
});
// First, attempt to match exact name and year
let movie = response.data.movies.find(
let movie = data.movies.find(
(movie) => movie.year === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = response.data.movies.find(
movie = data.movies.find(
(movie) => movie.year === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = response.data.movies.find((movie) => movie.year === year);
movie = data.movies.find((movie) => movie.year === year);
}
// One last try, try exact name match only
if (!movie) {
movie = response.data.movies.find((movie) => movie.title === name);
movie = data.movies.find((movie) => movie.title === name);
}
if (!movie) {
@ -139,19 +139,14 @@ class RottenTomatoes {
year?: number
): Promise<RTRating | null> {
try {
const response = await this.axios.get<RTMultiSearchResponse>(
'/v2.0/search/',
{
params: { q: name, limit: 10 },
}
);
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
});
let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0];
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
if (year) {
tvshow = response.data.tvSeries.find(
(series) => series.startYear === year
);
tvshow = data.tvSeries.find((series) => series.startYear === year);
}
if (!tvshow) {

@ -1,6 +1,7 @@
import Axios, { AxiosInstance } from 'axios';
import cacheManager from '../lib/cache';
import { SonarrSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface SonarrSeason {
seasonNumber: number;
@ -119,21 +120,23 @@ interface AddSeriesOptions {
searchNow?: boolean;
}
class SonarrAPI {
class SonarrAPI extends ExternalAPI {
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
}
private axios: AxiosInstance;
constructor({ url, apiKey }: { url: string; apiKey: string }) {
this.axios = Axios.create({
baseURL: url,
params: {
super(
url,
{
apikey: apiKey,
},
});
{
nodeCache: cacheManager.getCache('sonarr').data,
}
);
}
public async getSeries(): Promise<SonarrSeries[]> {
@ -280,9 +283,13 @@ class SonarrAPI {
public async getProfiles(): Promise<SonarrProfile[]> {
try {
const response = await this.axios.get<SonarrProfile[]>('/profile');
const data = await this.getRolling<SonarrProfile[]>(
'/profile',
undefined,
3600
);
return response.data;
return data;
} catch (e) {
logger.error('Something went wrong while retrieving Sonarr profiles.', {
label: 'Sonarr API',
@ -294,9 +301,13 @@ class SonarrAPI {
public async getRootFolders(): Promise<SonarrRootFolder[]> {
try {
const response = await this.axios.get<SonarrRootFolder[]>('/rootfolder');
const data = await this.getRolling<SonarrRootFolder[]>(
'/rootfolder',
undefined,
3600
);
return response.data;
return data;
} catch (e) {
logger.error(
'Something went wrong while retrieving Sonarr root folders.',

@ -1,934 +0,0 @@
import axios, { AxiosInstance } from 'axios';
export const ANIME_KEYWORD_ID = 210024;
interface SearchOptions {
query: string;
page?: number;
includeAdult?: boolean;
language?: string;
}
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
}
interface DiscoverTvOptions {
page?: number;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
}
interface TmdbMediaResult {
id: number;
media_type: string;
popularity: number;
poster_path?: string;
backdrop_path?: string;
vote_count: number;
vote_average: number;
genre_ids: number[];
overview: string;
original_language: string;
}
export interface TmdbMovieResult extends TmdbMediaResult {
media_type: 'movie';
title: string;
original_title: string;
release_date: string;
adult: boolean;
video: boolean;
}
export interface TmdbTvResult extends TmdbMediaResult {
media_type: 'tv';
name: string;
original_name: string;
origin_country: string[];
first_air_date: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
popularity: number;
profile_path?: string;
adult: boolean;
media_type: 'person';
known_for: (TmdbMovieResult | TmdbTvResult)[];
}
interface TmdbPaginatedResponse {
page: number;
total_results: number;
total_pages: number;
}
interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
}
interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
results: TmdbMovieResult[];
}
interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
results: TmdbTvResult[];
}
interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
dates: {
maximum: string;
minimum: string;
};
results: TmdbMovieResult[];
}
interface TmdbExternalIdResponse {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
}
export interface TmdbCreditCast {
cast_id: number;
character: string;
credit_id: string;
gender?: number;
id: number;
name: string;
order: number;
profile_path?: string;
}
export interface TmdbCreditCrew {
credit_id: string;
gender?: number;
id: number;
name: string;
profile_path?: string;
job: string;
department: string;
}
export interface TmdbExternalIds {
imdb_id?: string;
freebase_mid?: string;
freebase_id?: string;
tvdb_id?: number;
tvrage_id?: string;
facebook_id?: string;
instagram_id?: string;
twitter_id?: string;
}
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
adult: boolean;
backdrop_path?: string;
poster_path?: string;
budget: number;
genres: {
id: number;
name: string;
}[];
homepage?: string;
original_language: string;
original_title: string;
overview?: string;
popularity: number;
production_companies: {
id: number;
name: string;
logo_path?: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
revenue: number;
runtime?: number;
spoken_languages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
belongs_to_collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
external_ids: TmdbExternalIds;
videos: TmdbVideoResult;
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: 'YouTube';
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface TmdbTvEpisodeResult {
id: number;
air_date: string;
episode_number: number;
name: string;
overview: string;
production_code: string;
season_number: number;
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
}
export interface TmdbTvSeasonResult {
id: number;
air_date: string;
episode_count: number;
name: string;
overview: string;
poster_path?: string;
season_number: number;
}
export interface TmdbTvDetails {
id: number;
backdrop_path?: string;
created_by: {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path?: string;
}[];
episode_run_time: number[];
first_air_date: string;
genres: {
id: number;
name: string;
}[];
homepage: string;
in_production: boolean;
languages: string[];
last_air_date: string;
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
networks: {
id: number;
name: string;
logo_path: string;
origin_country: string;
}[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path?: string;
production_companies: {
id: number;
logo_path?: string;
name: string;
origin_country: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
}
export interface TmdbKeyword {
id: number;
name: string;
}
export interface TmdbPersonDetail {
id: number;
name: string;
deathday: string;
known_for_department: string;
also_known_as?: string[];
gender: number;
biography: string;
popularity: string;
place_of_birth?: string;
profile_path?: string;
adult: boolean;
imdb_id?: string;
homepage?: string;
}
export interface TmdbPersonCredit {
id: number;
original_language: string;
episode_count: number;
overview: string;
origin_country: string[];
original_name: string;
vote_count: number;
name: string;
media_type?: string;
popularity: number;
credit_id: string;
backdrop_path?: string;
first_air_date: string;
vote_average: number;
genre_ids?: number[];
poster_path?: string;
original_title: string;
video?: boolean;
title: string;
adult: boolean;
release_date: string;
}
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
character: string;
}
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
department: string;
job: string;
}
export interface TmdbPersonCombinedCredits {
id: number;
cast: TmdbPersonCreditCast[];
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
export interface TmdbCollection {
id: number;
name: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
parts: TmdbMovieResult[];
}
class TheMovieDb {
private apiKey = 'db55323b8d3e4154498498a75642b381';
private axios: AxiosInstance;
constructor() {
this.axios = axios.create({
baseURL: 'https://api.themoviedb.org/3',
params: {
api_key: this.apiKey,
},
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
public searchMulti = async ({
query,
page = 1,
includeAdult = false,
language = 'en-US',
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const response = await this.axios.get('/search/multi', {
params: { query, page, include_adult: includeAdult, language },
});
return response.data;
} catch (e) {
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};
public getPerson = async ({
personId,
language = 'en-US',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetail> => {
try {
const response = await this.axios.get<TmdbPersonDetail>(
`/person/${personId}`,
{
params: { language },
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
}
};
public getPersonCombinedCredits = async ({
personId,
language = 'en-US',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonCombinedCredits> => {
try {
const response = await this.axios.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: { language },
}
);
return response.data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
);
}
};
public getMovie = async ({
movieId,
language = 'en-US',
}: {
movieId: number;
language?: string;
}): Promise<TmdbMovieDetails> => {
try {
const response = await this.axios.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response: 'credits,external_ids,videos',
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
}
};
public getTvShow = async ({
tvId,
language = 'en-US',
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> => {
try {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: {
language,
append_to_response: 'credits,external_ids,keywords,videos',
},
});
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public getTvSeason = async ({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> => {
try {
const response = await this.axios.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language,
append_to_response: 'external_ids',
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMovieSimilar({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en-US',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({
tvId,
page = 1,
language = 'en-US',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
);
}
}
public async getTvSimilar({
tvId,
page = 1,
language = 'en-US',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/tv/${tvId}/similar`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
}
}
public getDiscoverMovies = async ({
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = 'en-US',
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
'/discover/movie',
{
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en-US',
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
'/discover/tv',
{
params: {
sort_by: sortBy,
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
}
};
public getUpcomingMovies = async ({
page = 1,
language = 'en-US',
}: {
page: number;
language: string;
}): Promise<TmdbUpcomingMoviesResponse> => {
try {
const response = await this.axios.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
}
};
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = 'en-US',
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchMultiResponse> => {
try {
const response = await this.axios.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getMovieTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchMovieResponse> => {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getTvTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchTvResponse> => {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public async getByExternalId({
externalId,
type,
language = 'en-US',
}:
| {
externalId: string;
type: 'imdb';
language?: string;
}
| {
externalId: number;
type: 'tvdb';
language?: string;
}): Promise<TmdbExternalIdResponse> {
try {
const response = await this.axios.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
}
}
public async getMovieByImdbId({
imdbId,
language = 'en-US',
}: {
imdbId: string;
language?: string;
}): Promise<TmdbMovieDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
type: 'imdb',
});
if (extResponse.movie_results[0]) {
const movie = await this.getMovie({
movieId: extResponse.movie_results[0].id,
language,
});
return movie;
}
throw new Error(
'[TMDB] Failed to find a title with the provided IMDB id'
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get movie by external imdb ID: ${e.message}`
);
}
}
public async getShowByTvdbId({
tvdbId,
language = 'en-US',
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
const tvshow = await this.getTvShow({
tvId: extResponse.tv_results[0].id,
language,
});
return tvshow;
}
throw new Error(
`[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
);
}
}
public async getCollection({
collectionId,
language = 'en-US',
}: {
collectionId: number;
language?: string;
}): Promise<TmdbCollection> {
try {
const response = await this.axios.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
}
}
}
export default TheMovieDb;

@ -0,0 +1 @@
export const ANIME_KEYWORD_ID = 210024;

@ -0,0 +1,599 @@
import cacheManager from '../../lib/cache';
import ExternalAPI from '../externalapi';
import {
TmdbCollection,
TmdbExternalIdResponse,
TmdbMovieDetails,
TmdbPersonCombinedCredits,
TmdbPersonDetail,
TmdbSearchMovieResponse,
TmdbSearchMultiResponse,
TmdbSearchTvResponse,
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
} from './interfaces';
interface SearchOptions {
query: string;
page?: number;
includeAdult?: boolean;
language?: string;
}
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
}
interface DiscoverTvOptions {
page?: number;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
}
class TheMovieDb extends ExternalAPI {
constructor() {
super(
'https://api.themoviedb.org/3',
{
api_key: 'db55323b8d3e4154498498a75642b381',
},
{
nodeCache: cacheManager.getCache('tmdb').data,
}
);
}
public searchMulti = async ({
query,
page = 1,
includeAdult = false,
language = 'en',
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
params: { query, page, include_adult: includeAdult, language },
});
return data;
} catch (e) {
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};
public getPerson = async ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetail> => {
try {
const data = await this.get<TmdbPersonDetail>(`/person/${personId}`, {
params: { language },
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
}
};
public getPersonCombinedCredits = async ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonCombinedCredits> => {
try {
const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: { language },
}
);
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
);
}
};
public getMovie = async ({
movieId,
language = 'en',
}: {
movieId: number;
language?: string;
}): Promise<TmdbMovieDetails> => {
try {
const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response: 'credits,external_ids,videos',
},
},
43200
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
}
};
public getTvShow = async ({
tvId,
language = 'en',
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> => {
try {
const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`,
{
params: {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos',
},
},
43200
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public getTvSeason = async ({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> => {
try {
const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language,
append_to_response: 'external_ids',
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMovieSimilar({
movieId,
page = 1,
language = 'en',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({
tvId,
page = 1,
language = 'en',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
);
}
}
public async getTvSimilar({
tvId,
page = 1,
language = 'en',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
params: {
page,
language,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
}
}
public getDiscoverMovies = async ({
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = 'en',
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en',
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page,
language,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
}
};
public getUpcomingMovies = async ({
page = 1,
language = 'en',
}: {
page: number;
language: string;
}): Promise<TmdbUpcomingMoviesResponse> => {
try {
const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
}
};
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = 'en',
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getMovieTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getTvTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public async getByExternalId({
externalId,
type,
language = 'en',
}:
| {
externalId: string;
type: 'imdb';
language?: string;
}
| {
externalId: number;
type: 'tvdb';
language?: string;
}): Promise<TmdbExternalIdResponse> {
try {
const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
}
}
public async getMovieByImdbId({
imdbId,
language = 'en',
}: {
imdbId: string;
language?: string;
}): Promise<TmdbMovieDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
type: 'imdb',
});
if (extResponse.movie_results[0]) {
const movie = await this.getMovie({
movieId: extResponse.movie_results[0].id,
language,
});
return movie;
}
throw new Error(
'[TMDB] Failed to find a title with the provided IMDB id'
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get movie by external imdb ID: ${e.message}`
);
}
}
public async getShowByTvdbId({
tvdbId,
language = 'en',
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
const tvshow = await this.getTvShow({
tvId: extResponse.tv_results[0].id,
language,
});
return tvshow;
}
throw new Error(
`[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
);
}
}
public async getCollection({
collectionId,
language = 'en',
}: {
collectionId: number;
language?: string;
}): Promise<TmdbCollection> {
try {
const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
}
}
}
export default TheMovieDb;

@ -0,0 +1,346 @@
interface TmdbMediaResult {
id: number;
media_type: string;
popularity: number;
poster_path?: string;
backdrop_path?: string;
vote_count: number;
vote_average: number;
genre_ids: number[];
overview: string;
original_language: string;
}
export interface TmdbMovieResult extends TmdbMediaResult {
media_type: 'movie';
title: string;
original_title: string;
release_date: string;
adult: boolean;
video: boolean;
}
export interface TmdbTvResult extends TmdbMediaResult {
media_type: 'tv';
name: string;
original_name: string;
origin_country: string[];
first_air_date: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
popularity: number;
profile_path?: string;
adult: boolean;
media_type: 'person';
known_for: (TmdbMovieResult | TmdbTvResult)[];
}
interface TmdbPaginatedResponse {
page: number;
total_results: number;
total_pages: number;
}
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
}
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
results: TmdbMovieResult[];
}
export interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
results: TmdbTvResult[];
}
export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
dates: {
maximum: string;
minimum: string;
};
results: TmdbMovieResult[];
}
export interface TmdbExternalIdResponse {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
}
export interface TmdbCreditCast {
cast_id: number;
character: string;
credit_id: string;
gender?: number;
id: number;
name: string;
order: number;
profile_path?: string;
}
export interface TmdbAggregateCreditCast extends TmdbCreditCast {
roles: {
credit_id: string;
character: string;
episode_count: number;
}[];
}
export interface TmdbCreditCrew {
credit_id: string;
gender?: number;
id: number;
name: string;
profile_path?: string;
job: string;
department: string;
}
export interface TmdbExternalIds {
imdb_id?: string;
freebase_mid?: string;
freebase_id?: string;
tvdb_id?: number;
tvrage_id?: string;
facebook_id?: string;
instagram_id?: string;
twitter_id?: string;
}
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
adult: boolean;
backdrop_path?: string;
poster_path?: string;
budget: number;
genres: {
id: number;
name: string;
}[];
homepage?: string;
original_language: string;
original_title: string;
overview?: string;
popularity: number;
production_companies: {
id: number;
name: string;
logo_path?: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
revenue: number;
runtime?: number;
spoken_languages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
belongs_to_collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
external_ids: TmdbExternalIds;
videos: TmdbVideoResult;
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: 'YouTube';
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface TmdbTvEpisodeResult {
id: number;
air_date: string;
episode_number: number;
name: string;
overview: string;
production_code: string;
season_number: number;
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
}
export interface TmdbTvSeasonResult {
id: number;
air_date: string;
episode_count: number;
name: string;
overview: string;
poster_path?: string;
season_number: number;
}
export interface TmdbTvDetails {
id: number;
backdrop_path?: string;
created_by: {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path?: string;
}[];
episode_run_time: number[];
first_air_date: string;
genres: {
id: number;
name: string;
}[];
homepage: string;
in_production: boolean;
languages: string[];
last_air_date: string;
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
networks: {
id: number;
name: string;
logo_path: string;
origin_country: string;
}[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path?: string;
production_companies: {
id: number;
logo_path?: string;
name: string;
origin_country: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
vote_average: number;
vote_count: number;
aggregate_credits: {
cast: TmdbAggregateCreditCast[];
};
credits: {
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
}
export interface TmdbKeyword {
id: number;
name: string;
}
export interface TmdbPersonDetail {
id: number;
name: string;
deathday: string;
known_for_department: string;
also_known_as?: string[];
gender: number;
biography: string;
popularity: string;
place_of_birth?: string;
profile_path?: string;
adult: boolean;
imdb_id?: string;
homepage?: string;
}
export interface TmdbPersonCredit {
id: number;
original_language: string;
episode_count: number;
overview: string;
origin_country: string[];
original_name: string;
vote_count: number;
name: string;
media_type?: string;
popularity: number;
credit_id: string;
backdrop_path?: string;
first_air_date: string;
vote_average: number;
genre_ids?: number[];
poster_path?: string;
original_title: string;
video?: boolean;
title: string;
adult: boolean;
release_date: string;
}
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
character: string;
}
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
department: string;
job: string;
}
export interface TmdbPersonCombinedCredits {
id: number;
cast: TmdbPersonCreditCast[];
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
export interface TmdbCollection {
id: number;
name: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
parts: TmdbMovieResult[];
}

@ -15,7 +15,8 @@ import { User } from './User';
import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import SeasonRequest from './SeasonRequest';
@ -414,6 +415,15 @@ export class MediaRequest {
searchNow: !radarrSettings.preventSearch,
})
.then(async (radarrMovie) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
throw new Error('Media data is missing');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =

@ -8,7 +8,11 @@ import {
RelationCount,
AfterLoad,
} from 'typeorm';
import { Permission, hasPermission } from '../lib/permissions';
import {
Permission,
hasPermission,
PermissionCheckOptions,
} from '../lib/permissions';
import { MediaRequest } from './MediaRequest';
import bcrypt from 'bcrypt';
import path from 'path';
@ -85,8 +89,11 @@ export class User {
return filtered;
}
public hasPermission(permissions: Permission | Permission[]): boolean {
return !!hasPermission(permissions, this.permissions);
public hasPermission(
permissions: Permission | Permission[],
options?: PermissionCheckOptions
): boolean {
return !!hasPermission(permissions, this.permissions, options);
}
public passwordMatch(password: string): Promise<boolean> {

@ -7,7 +7,21 @@ export interface SettingsAboutResponse {
export interface PublicSettingsResponse {
initialized: boolean;
applicationTitle: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
hideAvailable: boolean;
}
export interface CacheItem {
id: string;
name: string;
stats: {
hits: number;
misses: number;
keys: number;
ksize: number;
vsize: number;
};
}

@ -1,10 +1,11 @@
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi';
import TheMovieDb, {
import TheMovieDb from '../../api/themoviedb';
import {
TmdbMovieDetails,
TmdbTvDetails,
} from '../../api/themoviedb';
} from '../../api/themoviedb/interfaces';
import Media from '../../entity/Media';
import { MediaStatus, MediaType } from '../../constants/media';
import logger from '../../logger';

@ -2,7 +2,8 @@ import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import SonarrAPI, { SonarrSeries } from '../../api/sonarr';
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
import TheMovieDb from '../../api/themoviedb';
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
@ -242,9 +243,19 @@ class JobSonarrSync {
isAllSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
(season) =>
season[server4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE ||
season[server4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) =>
season[server4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);

@ -0,0 +1,60 @@
import NodeCache from 'node-cache';
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
class Cache {
public id: AvailableCacheIds;
public data: NodeCache;
public name: string;
constructor(
id: AvailableCacheIds,
name: string,
options: { stdTtl?: number; checkPeriod?: number } = {}
) {
this.id = id;
this.name = name;
this.data = new NodeCache({
stdTTL: options.stdTtl ?? DEFAULT_TTL,
checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD,
});
}
public getStats() {
return this.data.getStats();
}
public flush(): void {
this.data.flushAll();
}
}
class CacheManager {
private availableCaches: Record<AvailableCacheIds, Cache> = {
tmdb: new Cache('tmdb', 'TMDb API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
radarr: new Cache('radarr', 'Radarr API'),
sonarr: new Cache('sonarr', 'Sonarr API'),
rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {
return this.availableCaches[id];
}
public getAllCaches(): Record<string, Cache> {
return this.availableCaches;
}
}
const cacheManager = new CacheManager();
export default cacheManager;

@ -203,7 +203,10 @@ class DiscordAgent
description: payload.message,
color,
timestamp: new Date().toISOString(),
author: { name: 'Overseerr', url: settings.main.applicationUrl },
author: {
name: settings.main.applicationTitle,
url: settings.main.applicationUrl,
},
fields: [
...fields,
// If we have extra data, map it to fields for discord notifications
@ -236,6 +239,7 @@ class DiscordAgent
): Promise<boolean> {
logger.debug('Sending discord notification', { label: 'Notifications' });
try {
const settings = getSettings();
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
@ -243,7 +247,7 @@ class DiscordAgent
}
await axios.post(webhookUrl, {
username: 'Overseerr',
username: settings.main.applicationTitle,
embeds: [this.buildEmbed(type, payload)],
} as DiscordWebhookPayload);

@ -36,7 +36,7 @@ class EmailAgent
private async sendMediaRequestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
@ -65,6 +65,7 @@ class EmailAgent
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'New Request',
},
});
@ -81,7 +82,7 @@ class EmailAgent
private async sendMediaFailedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
@ -111,6 +112,7 @@ class EmailAgent
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Failed Request',
},
});
@ -127,7 +129,7 @@ class EmailAgent
private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@ -149,6 +151,7 @@ class EmailAgent
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Approved',
},
});
@ -164,7 +167,7 @@ class EmailAgent
private async sendMediaDeclinedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@ -186,6 +189,7 @@ class EmailAgent
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Declined',
},
});
@ -201,7 +205,7 @@ class EmailAgent
private async sendMediaAvailableEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@ -223,6 +227,7 @@ class EmailAgent
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Now Available',
},
});
@ -238,7 +243,7 @@ class EmailAgent
private async sendTestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@ -250,6 +255,7 @@ class EmailAgent
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
},
});
return true;

@ -66,7 +66,7 @@ class PushoverAgent
message += `<b>Status</b>\nProcessing Request\n`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = 'Now available!';
messageTitle = 'Now Available';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
@ -81,7 +81,6 @@ class PushoverAgent
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${username}\n`;
break;
@ -89,7 +88,7 @@ class PushoverAgent
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `<a href="${actionUrl}">Open in Overseerr</a>`;
message += `<a href="${actionUrl}">Open in ${settings.main.applicationTitle}</a>`;
}
return { title: messageTitle, message };

@ -58,7 +58,7 @@ class SlackAgent
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
let header = 'Overseerr';
let header = settings.main.applicationTitle;
let actionUrl: string | undefined;
const fields: EmbedField[] = [];
@ -191,7 +191,7 @@ class SlackAgent
value: 'open_overseerr',
text: {
type: 'plain_text',
text: 'Open Overseerr',
text: `Open ${settings.main.applicationTitle}`,
},
},
],

@ -98,7 +98,7 @@ class TelegramAgent
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\[Open in Overseerr\]\(${actionUrl}\)`;
message += `\[Open in ${settings.main.applicationTitle}\]\(${actionUrl}\)`;
}
/* eslint-enable */

@ -13,6 +13,11 @@ export enum Permission {
REQUEST_4K_MOVIE = 2048,
REQUEST_4K_TV = 4096,
REQUEST_ADVANCED = 8192,
REQUEST_VIEW = 16384,
}
export interface PermissionCheckOptions {
type: 'and' | 'or';
}
/**
@ -22,10 +27,12 @@ export enum Permission {
*
* @param permissions Single permission or array of permissions
* @param value users current permission value
* @param options Extra options to control permission check behavior (mainly for arrays)
*/
export const hasPermission = (
permissions: Permission | Permission[],
value: number
value: number,
options: PermissionCheckOptions = { type: 'and' }
): boolean => {
let total = 0;
@ -35,8 +42,15 @@ export const hasPermission = (
}
if (Array.isArray(permissions)) {
// Combine all permission values into one
total = permissions.reduce((a, v) => a + v, 0);
if (value & Permission.ADMIN) {
return true;
}
switch (options.type) {
case 'and':
return permissions.every((permission) => !!(value & permission));
case 'or':
return permissions.some((permission) => !!(value & permission));
}
} else {
total = permissions;
}

@ -50,10 +50,12 @@ export interface SonarrSettings extends DVRSettings {
export interface MainSettings {
apiKey: string;
applicationTitle: string;
applicationUrl: string;
csrfProtection: boolean;
defaultPermissions: number;
hideAvailable: boolean;
localLogin: boolean;
trustProxy: boolean;
}
@ -62,9 +64,11 @@ interface PublicSettings {
}
interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
hideAvailable: boolean;
}
export interface NotificationAgentConfig {
@ -158,10 +162,12 @@ class Settings {
clientId: uuidv4(),
main: {
apiKey: '',
applicationTitle: 'Overseerr',
applicationUrl: '',
csrfProtection: false,
defaultPermissions: Permission.REQUEST,
hideAvailable: false,
localLogin: true,
trustProxy: false,
},
plex: {
@ -289,13 +295,15 @@ class Settings {
get fullPublicSettings(): FullPublicSettings {
return {
...this.data.public,
applicationTitle: this.data.main.applicationTitle,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault
),
series4kEnabled: this.data.sonarr.some(
(sonarr) => sonarr.is4k && sonarr.isDefault
),
hideAvailable: this.data.main.hideAvailable,
};
}

@ -1,6 +1,6 @@
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import { Permission } from '../lib/permissions';
import { Permission, PermissionCheckOptions } from '../lib/permissions';
import { getSettings } from '../lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
@ -34,10 +34,11 @@ export const checkUser: Middleware = async (req, _res, next) => {
};
export const isAuthenticated = (
permissions?: Permission | Permission[]
permissions?: Permission | Permission[],
options?: PermissionCheckOptions
): Middleware => {
const authMiddleware: Middleware = (req, res, next) => {
if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) {
res.status(403).json({
status: 403,
error: 'You do not have permission to access this endpoint',

@ -1,4 +1,4 @@
import { TmdbCollection } from '../api/themoviedb';
import type { TmdbCollection } from '../api/themoviedb/interfaces';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import { mapMovieResult, MovieResult } from './Search';

@ -1,4 +1,4 @@
import { TmdbMovieDetails } from '../api/themoviedb';
import type { TmdbMovieDetails } from '../api/themoviedb/interfaces';
import {
ProductionCompany,
Genre,

@ -1,8 +1,8 @@
import {
import type {
TmdbPersonCreditCast,
TmdbPersonCreditCrew,
TmdbPersonDetail,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import Media from '../entity/Media';
export interface PersonDetail {

@ -2,7 +2,7 @@ import type {
TmdbMovieResult,
TmdbPersonResult,
TmdbTvResult,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '../constants/media';
import Media from '../entity/Media';

@ -3,19 +3,19 @@ import {
ProductionCompany,
Cast,
Crew,
mapCast,
mapAggregateCast,
mapCrew,
ExternalIds,
mapExternalIds,
Keyword,
mapVideos,
} from './common';
import {
import type {
TmdbTvEpisodeResult,
TmdbTvSeasonResult,
TmdbTvDetails,
TmdbSeasonWithEpisodes,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import type Media from '../entity/Media';
import { Video } from './Movie';
@ -193,7 +193,7 @@ export const mapTvDetails = (
: undefined,
posterPath: show.poster_path,
credits: {
cast: show.credits.cast.map(mapCast),
cast: show.aggregate_credits.cast.map(mapAggregateCast),
crew: show.credits.crew.map(mapCrew),
},
externalIds: mapExternalIds(show.external_ids),

@ -1,10 +1,11 @@
import {
import type {
TmdbCreditCast,
TmdbAggregateCreditCast,
TmdbCreditCrew,
TmdbExternalIds,
TmdbVideo,
TmdbVideoResult,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import { Video } from '../models/Movie';
@ -68,6 +69,18 @@ export const mapCast = (person: TmdbCreditCast): Cast => ({
profilePath: person.profile_path,
});
export const mapAggregateCast = (person: TmdbAggregateCreditCast): Cast => ({
castId: person.cast_id,
// the first role is the one for which the actor appears the most as
character: person.roles[0].character,
creditId: person.roles[0].credit_id,
id: person.id,
name: person.name,
order: person.order,
gender: person.gender,
profilePath: person.profile_path,
});
export const mapCrew = (person: TmdbCreditCrew): Crew => ({
creditId: person.credit_id,
department: person.department,

@ -134,10 +134,13 @@ authRoutes.post('/login', async (req, res, next) => {
});
authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { email?: string; password?: string };
if (!body.email || !body.password) {
if (!settings.main.localLogin) {
return res.status(500).json({ error: 'Local user login is disabled' });
} else if (!body.email || !body.password) {
return res
.status(500)
.json({ error: 'You must provide an email and a password' });

@ -15,6 +15,7 @@ import personRoutes from './person';
import collectionRoutes from './collection';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
const router = Router();
@ -27,6 +28,13 @@ router.get('/status', (req, res) => {
});
});
router.get('/status/appdata', (_req, res) => {
return res.status(200).json({
appData: appDataStatus(),
appDataPath: appDataPath(),
});
});
router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
router.get('/settings/public', (_req, res) => {
const settings = getSettings();

@ -42,24 +42,28 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
);
return res.status(200).json({
cast: combinedCredits.cast.map((result) =>
mapCastCredits(
result,
castMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
cast: combinedCredits.cast
.map((result) =>
mapCastCredits(
result,
castMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
)
)
),
crew: combinedCredits.crew.map((result) =>
mapCrewCredits(
result,
crewMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
.filter((item) => !item.adult),
crew: combinedCredits.crew
.map((result) =>
mapCrewCredits(
result,
crewMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
)
)
),
.filter((item) => !item.adult),
id: combinedCredits.id,
});
});

@ -9,6 +9,7 @@ import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import SeasonRequest from '../entity/SeasonRequest';
import logger from '../logger';
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import { User } from '../entity/User';
const requestRoutes = Router();
@ -56,7 +57,8 @@ requestRoutes.get('/', async (req, res, next) => {
}
const [requests, requestCount] = req.user?.hasPermission(
Permission.MANAGE_REQUESTS
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
? await requestRepository.findAndCount({
order: sortFilter,
@ -94,8 +96,28 @@ requestRoutes.post(
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try {
let requestUser = req.user;
if (
req.body.userId &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId },
});
}
const tmdbMedia =
req.body.mediaType === 'movie'
? await tmdb.getMovie({ movieId: req.body.mediaId })
@ -151,7 +173,7 @@ requestRoutes.post(
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
requestedBy: req.user,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
@ -212,7 +234,7 @@ requestRoutes.post(
media: {
id: media.id,
} as Media,
requestedBy: req.user,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
@ -292,6 +314,7 @@ requestRoutes.put<{ requestId: string }>(
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try {
const request = await requestRepository.findOne(
Number(req.params.requestId)
@ -301,10 +324,30 @@ requestRoutes.put<{ requestId: string }>(
return next({ status: 404, message: 'Request not found' });
}
let requestUser = req.user;
if (
req.body.userId &&
!(
req.user?.hasPermission(Permission.MANAGE_USERS) &&
req.user?.hasPermission(Permission.MANAGE_REQUESTS)
)
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId },
});
}
if (req.body.mediaType === 'movie') {
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.requestedBy = requestUser as User;
requestRepository.save(request);
} else if (req.body.mediaType === 'tv') {
@ -312,6 +355,7 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.requestedBy = requestUser as User;
const requestedSeasons = req.body.seasons as number[] | undefined;

@ -16,6 +16,7 @@ import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces';
import notificationRoutes from './notifications';
import sonarrRoutes from './sonarr';
import radarrRoutes from './radarr';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
const settingsRoutes = Router();
@ -273,6 +274,32 @@ settingsRoutes.get<{ jobId: string }>(
}
);
settingsRoutes.get('/cache', (req, res) => {
const caches = cacheManager.getAllCaches();
return res.status(200).json(
Object.values(caches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}))
);
});
settingsRoutes.get<{ cacheId: AvailableCacheIds }>(
'/cache/:cacheId/flush',
(req, res, next) => {
const cache = cacheManager.getCache(req.params.cacheId);
if (cache) {
cache.flush();
return res.status(204).send();
}
next({ status: 404, message: 'Cache does not exist.' });
}
);
settingsRoutes.get(
'/initialize',
isAuthenticated(Permission.ADMIN),

@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
| #{applicationTitle}
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
@ -75,8 +75,8 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
tr
td
table(cellpadding='0' cellspacing='0' role='presentation')
img(src=imageUrl alt='')
p
a(href=actionUrl style='color: #3869d4')
img(src=imageUrl alt='')
p(style='\
font-size: 16px;\
line-height: 24px;\
@ -92,7 +92,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
margin-bottom: 20px;\
color: #51545e;\
')
a(href=actionUrl style='color: #3869d4') Open Media in Overseerr
a(href=actionUrl style='color: #3869d4') Open in #{applicationTitle}
tr
td
table.sm-w-full(align='center' style='\
@ -111,4 +111,4 @@ tr
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.
| #{applicationTitle}

@ -1,224 +0,0 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<link
href="https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;amp;display=swap"
rel="stylesheet"
media="screen"
/>
<!--[if mso]>
<xml
><o:OfficeDocumentSettings
><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings
></xml
>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Segoe UI', sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<style>
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
}
</style>
</head>
<body
style="
margin: 0;
padding: 0;
width: 100%;
word-break: break-word;
-webkit-font-smoothing: antialiased;
background-color: #f2f4f6;
"
>
<div role="article" aria-roledescription="email" aria-label="" lang="en">
<table
style="
background-color: #f2f4f6;
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;
width: 100%;
"
width="100%"
bgcolor="#f2f4f6"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td align="center">
<table
style="width: 100%"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td
align="center"
style="
font-size: 16px;
padding-top: 25px;
padding-bottom: 25px;
text-align: center;
"
>
<a
href="https://example.com"
style="
text-shadow: 0 1px 0 #ffffff;
font-weight: 700;
font-size: 16px;
color: #a8aaaf;
text-decoration: none;
"
>
Overseerr
</a>
</td>
</tr>
<tr>
<td style="width: 100%" width="100%">
<table
align="center"
class="sm-w-full"
style="
background-color: #ffffff;
margin-left: auto;
margin-right: auto;
width: 570px;
"
width="570"
bgcolor="#ffffff"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td style="padding: 45px">
<div style="font-size: 16px">
{{body}}
<br />
<br />
<p style="margin-top: 4px; text-align: center">
{{media_name}
</p>
<table
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td>
<table
cellpadding="0"
cellspacing="0"
role="presentation"
>
<img src="{{image_url}}" alt="" />
<p></p>
</table>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p
style="
font-size: 16px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
color: #51545e;
"
>
Requested by {{requester_name}} at {{timestamp}}
</p>
<p
style="
font-size: 13px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
color: #51545e;
"
>
<a href="{{action_url}}" style="color: #3869d4"
>Open detail page</a
>
</p>
</td>
</tr>
</table>
</div>
<tr>
<td>
<table
align="center"
class="sm-w-full"
style="
margin-left: auto;
margin-right: auto;
text-align: center;
width: 570px;
"
width="570"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td align="center" style="font-size: 16px; padding: 45px">
<p
style="
font-size: 13px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
text-align: center;
color: #a8aaaf;
"
>
Overseerr.
</p>
</td>
</tr>
</table>
</td>
</tr>
</body>
</html>

@ -1 +1 @@
= `${requestType}: ${mediaName} - Overseerr`
= `${requestType}: ${mediaName} - ${applicationTitle}`

@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
| #{applicationTitle}
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
@ -76,7 +76,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
margin-bottom: 20px;\
color: #51545e;\
')
a(href=applicationUrl style='color: #3869d4') Open Overseerr
a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
tr
td
table.sm-w-full(align='center' style='\
@ -95,4 +95,4 @@ tr
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.
| #{applicationTitle}

@ -1 +1 @@
= `Password reset - Overseerr`
= `Password Reset - ${applicationTitle}`

@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
| #{applicationTitle}
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
@ -74,7 +74,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
margin-bottom: 20px;\
color: #51545e;\
')
a(href=applicationUrl style='color: #3869d4') Open Overseerr
a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
tr
td
table.sm-w-full(align='center' style='\
@ -93,4 +93,4 @@ tr
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.
| #{applicationTitle}

@ -1 +1 @@
= `Test Notification - Overseerr`
= `Test Notification - ${applicationTitle}`

@ -4,10 +4,6 @@ import type { User } from '../entity/User';
declare global {
namespace Express {
export interface Session {
userId?: number;
}
export interface Request {
user?: User;
}
@ -19,3 +15,11 @@ declare global {
next: NextFunction
) => Promise<void | NextFunction> | void | NextFunction;
}
// Declaration merging to apply our own types to SessionData
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
declare module 'express-session' {
export interface SessionData {
userId: number;
}
}

@ -0,0 +1,16 @@
import { existsSync } from 'fs';
import path from 'path';
const CONFIG_PATH = process.env.CONFIG_DIRECTORY
? process.env.CONFIG_DIRECTORY
: path.join(__dirname, '../../config');
const DOCKER_PATH = `${CONFIG_PATH}/DOCKER`;
export const appDataStatus = (): boolean => {
return !existsSync(DOCKER_PATH);
};
export const appDataPath = (): string => {
return CONFIG_PATH;
};

@ -2,7 +2,7 @@ import type {
TmdbMovieResult,
TmdbTvResult,
TmdbPersonResult,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
export const isMovie = (
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 49.994049 27.764576" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><g transform="translate(-80.836 -134.28)" stroke-width=".26458"><ellipse cx="104.39" cy="140.67" rx="3.0402" ry="2.5725" fill="#fff"/><path transform="translate(-8.3333e-8)" d="m85.973 162.05c-0.2783-9e-3 -0.76695-0.12188-1.0859-0.25035-0.31894-0.12847-0.75831-0.50788-0.97638-0.84312-0.27168-0.41768-0.81789-4.2912-1.7352-12.306-0.7363-6.4328-1.3387-11.951-1.3387-12.263 0-0.31189 0.36978-0.9125 0.82173-1.3347 0.55888-0.52208 1.0976-0.76762 1.6843-0.76762 0.4744 0 5.7899 0.65473 11.812 1.4549 6.0223 0.80022 11.395 1.5787 11.94 1.73 0.67007 0.18601 1.1196 0.52432 1.3891 1.0455 0.23816 0.46053 0.39842 1.4812 0.39842 2.5375 0 1.6585-0.0587 1.832-0.95642 2.8256-0.52604 0.58226-1.2554 1.7118-1.6209 2.51-0.56065 1.2246-0.6448 1.7259-0.5387 3.2089 0.0692 0.96664 0.33525 2.1733 0.59131 2.6815 0.25605 0.50816 0.9453 1.4214 1.5317 2.0295 1.0049 1.0421 1.0581 1.1768 0.92626 2.3462-0.0925 0.82058-0.35123 1.4496-0.76415 1.8577-0.57063 0.56398-1.5248 0.74328-11.099 2.0855-5.7608 0.80767-10.702 1.461-10.98 1.4517zm5.8777-7.1419c0.64371 9e-3 1.2219-0.14144 1.4082-0.36594 0.17385-0.20948 0.27773-0.71409 0.23083-1.1214-0.07317-0.63544-0.23231-0.76418-1.122-0.90763-0.5702-0.0919-1.1992-0.38942-1.3979-0.66108-0.25954-0.35495-0.34007-1.2022-0.2862-3.011l0.07497-2.517h3.7011l1.7217 4.2333 1.7217 4.2333 1.4787 0.0781c0.97936 0.0517 1.5672-0.0376 1.7409-0.26458 0.14423-0.18847 1.1888-2.5136 2.3212-5.1671 1.1324-2.6534 1.9897-5.0049 1.905-5.2255-0.0964-0.25118-0.49511-0.40113-1.0666-0.40113-0.50198 0-1.0918 0.0687-1.3108 0.15275-0.21893 0.084-1.0155 1.6914-1.7701 3.5719-0.75462 1.8805-1.4697 3.4191-1.589 3.4191-0.1193 0-0.45209-0.62507-0.73953-1.3891-0.28745-0.76398-0.8376-2.2224-1.2226-3.2408s-0.81858-1.971-0.9636-2.1167-1.5382-0.32442-3.0959-0.39718l-2.8323-0.13229-0.13229-1.8521-0.13229-1.8521h-2.6458l-0.13229 1.8365c-0.13098 1.8183-0.14096 1.8388-1.0075 2.062-0.81451 0.20989-0.86954 0.29343-0.79375 1.205 0.07128 0.85741 0.18035 0.99633 0.87518 1.1147l0.79375 0.13524 0.13229 3.2208c0.11196 2.726 0.2174 3.3355 0.68624 3.967 0.30467 0.4104 0.95952 0.88829 1.4552 1.062s1.3927 0.32252 1.9933 0.33073zm12.666-11.891c0.45112 0 0.96309-0.14287 1.1377-0.3175 0.17463-0.17462 0.3175-0.64237 0.3175-1.0394 0-0.39707-0.1871-0.90903-0.41577-1.1377-0.22868-0.22868-0.69642-0.41577-1.0394-0.41577-0.34301 0-0.81075 0.18709-1.0394 0.41577s-0.41577 0.74064-0.41577 1.1377c0 0.39706 0.14287 0.86481 0.3175 1.0394 0.17462 0.17463 0.68659 0.3175 1.1377 0.3175z" fill="#1b7d3d"/><path transform="translate(-8.3333e-8)" d="m114.46 154.52c-3.0274 0.0544-3.7379-0.0118-4.6609-0.43422-0.59897-0.27412-1.3995-0.90545-1.779-1.403-0.37946-0.49751-0.83257-1.5225-1.0069-2.2778-0.24624-1.0668-0.24624-1.6796 0-2.7464 0.17434-0.75526 0.61766-1.7674 0.98516-2.2492 0.3675-0.48183 1.049-1.0738 1.5144-1.3155 0.48523-0.25197 1.8056-0.5058 3.0952-0.59503l2.249-0.15561 0.13229-1.8521 0.1323-1.8521h2.9104v14.817zm-0.92604-2.3736 1.3229-0.0718v-5.8208l-1.6062-0.0775c-1.4422-0.0696-1.6785-5e-3 -2.3151 0.6314-0.38991 0.38991-0.72779 1.0201-0.75085 1.4004-0.023 0.38034-0.0238 0.92965-2e-3 1.2207 0.0222 0.29104 0.10377 0.80075 0.18131 1.1327 0.0775 0.33193 0.52474 0.8405 0.99377 1.1302 0.615 0.37978 1.2217 0.50661 2.1757 0.45481zm9.7151 2.4359c-2.5936 4e-3 -3.5477-0.0819-3.6425-0.32887-0.0706-0.18407-0.0967-3.5476-0.0578-7.4745l0.0706-7.1398h2.9104l0.13229 1.8521 0.13229 1.8521 2.2687 0.15541c1.6342 0.11195 2.5591 0.31887 3.3073 0.73989 0.57124 0.32147 1.3303 1.0254 1.6868 1.5644 0.45872 0.69347 0.67992 1.4556 0.75677 2.6074 0.0597 0.89511-0.0338 2.059-0.20789 2.5864-0.17406 0.52742-0.62874 1.33-1.0104 1.7836-0.38165 0.45358-1.1751 1.0432-1.7632 1.3104-0.86286 0.39194-1.7476 0.48683-4.5833 0.49152zm0.84267-2.3754c0.64994 0 1.4871-0.1161 1.8603-0.25799 0.3732-0.14189 0.92337-0.64791 1.2226-1.1245 0.41645-0.66331 0.5175-1.173 0.43097-2.1738-0.0877-1.0148-0.27505-1.4467-0.83705-1.9301-0.61948-0.53286-0.95861-0.6115-2.3491-0.54474l-1.6251 0.078-0.0763 2.627c-0.042 1.4448-0.0159 2.7843 0.0578 2.9766 0.0835 0.21749 0.5806 0.34956 1.3158 0.34956z" fill="#fff"/></g></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@ -0,0 +1,42 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import Alert from '../Common/Alert';
const messages = defineMessages({
dockerVolumeMissing: 'Docker Volume Mount Missing',
dockerVolumeMissingDescription:
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
});
const AppDataWarning: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>(
'/api/v1/status/appdata'
);
if (!data && !error) {
return null;
}
if (!data) {
return null;
}
return (
<>
{!data.appData && (
<Alert title={intl.formatMessage(messages.dockerVolumeMissing)}>
{intl.formatMessage(messages.dockerVolumeMissingDescription, {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
appDataPath: data.appDataPath,
})}
</Alert>
)}
</>
);
};
export default AppDataWarning;

@ -1,5 +1,4 @@
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { useContext, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -18,6 +17,7 @@ import Modal from '../Common/Modal';
import Slider from '../Slider';
import TitleCard from '../TitleCard';
import Transition from '../Transition';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
overviewunavailable: 'Overview unavailable.',
@ -108,9 +108,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}}
>
<Head>
<title>{data.name} - Overseerr</title>
</Head>
<PageTitle title={data.name} />
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"

@ -77,7 +77,7 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
}
return (
<div className={`rounded-md p-4 mb-8 ${design.bgColor}`}>
<div className={`rounded-md p-4 mb-5 ${design.bgColor}`}>
<div className="flex">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3">

@ -92,8 +92,8 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
>
{text}
</button>
<span className="relative z-10 block -ml-px">
{children && (
{children && (
<span className="relative z-10 block -ml-px">
<button
type="button"
className={`relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out rounded-r-md focus:z-10 ${styleClasses.dropdownSideButtonClasses}`}
@ -117,25 +117,25 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
</svg>
)}
</button>
)}
<Transition
show={isOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
>
<div className="py-1">{children}</div>
<Transition
show={isOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
>
<div className="py-1">{children}</div>
</div>
</div>
</div>
</Transition>
</span>
</Transition>
</span>
)}
</span>
);
};

@ -11,14 +11,14 @@ const Header: React.FC<HeaderProps> = ({
subtext,
}) => {
return (
<div className="md:flex md:items-center md:justify-between mt-8 mb-8">
<div className="mt-8 md:flex md:items-center md:justify-between">
<div className={`flex-1 min-w-0 mx-${extraMargin}`}>
<h2 className="text-2xl font-bold leading-7 text-gray-100 sm:text-4xl sm:leading-9 truncate sm:overflow-visible">
<span className="bg-clip-text text-transparent bg-gradient-to-br from-indigo-400 to-purple-400">
<h2 className="mb-4 text-2xl font-bold leading-7 text-gray-100 truncate sm:text-4xl sm:leading-9 sm:overflow-visible md:mb-0">
<span className="text-transparent bg-clip-text bg-gradient-to-br from-indigo-400 to-purple-400">
{children}
</span>
</h2>
{subtext && <div className="text-gray-400 mt-2">{subtext}</div>}
{subtext && <div className="mt-2 text-gray-400">{subtext}</div>}
</div>
</div>
);

@ -7,11 +7,13 @@ interface ListItemProps {
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
return (
<div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-200">{title}</dt>
<dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2">
<span className="flex-grow">{children}</span>
</dd>
<div>
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="block text-sm font-medium text-gray-400">{title}</dt>
<dd className="flex text-sm text-white sm:mt-0 sm:col-span-2">
<span className="flex-grow">{children}</span>
</dd>
</div>
</div>
);
};
@ -25,12 +27,10 @@ const List: React.FC<ListProps> = ({ title, subTitle, children }) => {
return (
<>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-100">{title}</h3>
{subTitle && (
<p className="mt-1 max-w-2xl text-sm text-gray-300">{subTitle}</p>
)}
<h3 className="heading">{title}</h3>
{subTitle && <p className="description">{subTitle}</p>}
</div>
<div className="mt-5 border-t border-gray-800">
<div className="border-t border-gray-800 section">
<dl className="divide-y divide-gray-800">{children}</dl>
</div>
</>

@ -112,7 +112,7 @@ const Modal: React.FC<ModalProps> = ({
)}
<div
className={`mt-3 text-center sm:mt-0 sm:text-left ${
iconSvg ? 'sm:ml-4' : 'mb-6'
iconSvg ? 'sm:ml-4' : 'sm:mb-4'
}`}
>
{title && (

@ -0,0 +1,22 @@
import React from 'react';
import useSettings from '../../../hooks/useSettings';
import Head from 'next/head';
interface PageTitleProps {
title: string | (string | undefined)[];
}
const PageTitle: React.FC<PageTitleProps> = ({ title }) => {
const settings = useSettings();
return (
<Head>
<title>
{Array.isArray(title) ? title.filter(Boolean).join(' - ') : title} -{' '}
{settings.currentSettings.applicationTitle}
</title>
</Head>
);
};
export default PageTitle;

@ -3,7 +3,7 @@ import { withProperties } from '../../../utils/typeHelpers';
const TBody: React.FC = ({ children }) => {
return (
<tbody className="bg-gray-600 divide-y divide-gray-700">{children}</tbody>
<tbody className="bg-gray-800 divide-y divide-gray-700">{children}</tbody>
);
};
@ -71,9 +71,9 @@ const TD: React.FC<TDProps> = ({
const Table: React.FC = ({ children }) => {
return (
<div className="flex flex-col">
<div className="my-2 overflow-x-auto -mx-6 md:mx-0 lg:mx-0">
<div className="py-2 align-middle inline-block min-w-full">
<div className="shadow overflow-hidden sm:rounded-lg">
<div className="my-2 -mx-4 overflow-x-auto md:mx-0 lg:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">{children}</table>
</div>
</div>

@ -3,10 +3,11 @@ import { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
discovermovies: 'Popular Movies',
@ -20,6 +21,7 @@ interface SearchResult {
}
const DiscoverMovies: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
@ -68,9 +70,12 @@ const DiscoverMovies: React.FC = () => {
return (
<>
<Header>
<FormattedMessage {...messages.discovermovies} />
</Header>
<PageTitle title={intl.formatMessage(messages.discovermovies)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.discovermovies} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}

@ -2,11 +2,12 @@ import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { defineMessages, FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
discovertv: 'Popular Series',
@ -20,6 +21,7 @@ interface SearchResult {
}
const DiscoverTv: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
@ -67,9 +69,12 @@ const DiscoverTv: React.FC = () => {
return (
<>
<Header>
<FormattedMessage {...messages.discovertv} />
</Header>
<PageTitle title={intl.formatMessage(messages.discovertv)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.discovertv} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}

@ -7,10 +7,11 @@ import type {
} from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
trending: 'Trending',
@ -24,6 +25,7 @@ interface SearchResult {
}
const Trending: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
@ -74,9 +76,12 @@ const Trending: React.FC = () => {
return (
<>
<Header>
<FormattedMessage {...messages.trending} />
</Header>
<PageTitle title={intl.formatMessage(messages.trending)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.trending} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}

@ -3,10 +3,11 @@ import { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
upcomingmovies: 'Upcoming Movies',
@ -20,6 +21,7 @@ interface SearchResult {
}
const UpcomingMovies: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
@ -69,9 +71,12 @@ const UpcomingMovies: React.FC = () => {
return (
<>
<Header>
<FormattedMessage {...messages.upcomingmovies} />
</Header>
<PageTitle title={intl.formatMessage(messages.upcomingmovies)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.upcomingmovies} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}

@ -8,8 +8,10 @@ import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaI
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import RequestCard from '../RequestCard';
import MediaSlider from '../MediaSlider';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
discover: 'Discover',
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
@ -35,6 +37,7 @@ const Discover: React.FC = () => {
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">

@ -5,9 +5,13 @@ import Badge from '../Common/Badge';
interface DownloadBlockProps {
downloadItem: DownloadingItem;
is4k?: boolean;
}
const DownloadBlock: React.FC<DownloadBlockProps> = ({ downloadItem }) => {
const DownloadBlock: React.FC<DownloadBlockProps> = ({
downloadItem,
is4k = false,
}) => {
return (
<div className="p-4">
<div className="w-56 mb-2 text-sm truncate sm:w-80 md:w-full">
@ -17,26 +21,39 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({ downloadItem }) => {
<div
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
style={{
width: `${Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)}%`,
width: `${
downloadItem.size
? Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)
: 0
}%`,
}}
/>
<div className="absolute inset-0 flex items-center justify-center w-full h-6 text-xs">
<span>
{Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)}
{downloadItem.size
? Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)
: 0}
%
</span>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<Badge className="capitalize">{downloadItem.status}</Badge>
<span>
{is4k && (
<Badge badgeType="warning" className="mr-1">
4K
</Badge>
)}
<Badge className="capitalize">{downloadItem.status}</Badge>
</span>
<span>
ETA{' '}
{downloadItem.estimatedCompletionTime ? (

@ -1,30 +1,34 @@
import React from 'react';
import TmdbLogo from '../../assets/services/tmdb.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import ImdbLogo from '../../assets/services/imdb.svg';
import RTLogo from '../../assets/services/rt.svg';
import PlexLogo from '../../assets/services/plex.svg';
import { MediaType } from '../../../server/constants/media';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
imdbId?: string;
tmdbId?: number;
tvdbId?: number;
imdbId?: string;
rtUrl?: string;
plexUrl?: string;
}
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
imdbId,
mediaType,
tmdbId,
tvdbId,
imdbId,
rtUrl,
mediaType,
plexUrl,
}) => {
return (
<div className="flex justify-end items-center">
<div className="flex items-center justify-end">
{plexUrl && (
<a
href={plexUrl}
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@ -34,17 +38,27 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{tmdbId && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
<TmdbLogo />
</a>
)}
{tvdbId && mediaType === MediaType.TV && (
<a
href={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
<TvdbLogo />
</a>
)}
{imdbId && (
<a
href={`https://www.imdb.com/title/${imdbId}`}
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@ -54,7 +68,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{rtUrl && (
<a
href={`${rtUrl}`}
className="w-14 mx-2 opacity-50 hover:opacity-100 transition duration-300"
className="mx-2 transition duration-300 opacity-50 w-14 hover:opacity-100"
target="_blank"
rel="noreferrer"
>

@ -17,61 +17,65 @@ type AvailableLanguageObject = Record<
>;
const availableLanguages: AvailableLanguageObject = {
de: {
code: 'de',
display: 'Deutsch',
},
en: {
code: 'en',
display: 'English',
},
ja: {
code: 'ja',
display: 'Japanese',
es: {
code: 'es',
display: 'Español',
},
fr: {
code: 'fr',
display: 'Français',
},
'nb-NO': {
code: 'nb-NO',
display: 'Norwegian Bokmål',
},
de: {
code: 'de',
display: 'German',
it: {
code: 'it',
display: 'Italiano',
},
ru: {
code: 'ru',
display: 'Russian',
hu: {
code: 'hu',
display: 'Magyar',
},
nl: {
code: 'nl',
display: 'Nederlands',
},
es: {
code: 'es',
display: 'Spanish',
},
it: {
code: 'it',
display: 'Italian',
'nb-NO': {
code: 'nb-NO',
display: 'Norsk Bokmål',
},
'pt-BR': {
code: 'pt-BR',
display: 'Portuguese (Brazil)',
display: 'Português (Brasil)',
},
'pt-PT': {
code: 'pt-PT',
display: 'Portuguese (Portugal)',
display: 'Português (Portugal)',
},
sv: {
code: 'sv',
display: 'Svenska',
},
ru: {
code: 'ru',
display: 'pусский',
},
sr: {
code: 'sr',
display: 'Serbian',
display: 'српски језик‬',
},
sv: {
code: 'sv',
display: 'Swedish',
ja: {
code: 'ja',
display: '日本語',
},
'zh-Hant': {
code: 'zh-Hant',
display: 'Chinese (Traditional)',
'zh-TW': {
code: 'zh-TW',
display: '中文(臺灣)',
},
};
@ -113,10 +117,10 @@ const LanguagePicker: React.FC = () => {
leaveTo="transform opacity-0 scale-95"
>
<div
className="absolute right-0 w-48 mt-2 origin-top-right rounded-md shadow-lg"
className="absolute right-0 w-56 mt-2 origin-top-right rounded-md shadow-lg"
ref={dropdownRef}
>
<div className="px-2 py-2 bg-gray-700 rounded-md ring-1 ring-black ring-opacity-5">
<div className="px-3 py-2 bg-gray-700 rounded-md ring-1 ring-black ring-opacity-5">
<div>
<label
htmlFor="language"
@ -126,7 +130,7 @@ const LanguagePicker: React.FC = () => {
</label>
<select
id="language"
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white bg-gray-700 border-gray-600 form-select focus:outline-none focus:ring-indigo focus:border-blue-800 sm:text-sm sm:leading-5"
className="rounded-md"
onChange={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales)
}

@ -176,7 +176,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<div className="flex-shrink-0 flex items-center px-4">
<span className="text-xl text-gray-50">
<a href="/">
<img src="/logo.png" alt="Overseerr Logo" />
<img src="/logo.png" alt="Logo" />
</a>
</span>
</div>
@ -201,7 +201,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
}}
role="button"
tabIndex={0}
className={`group flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150
className={`flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150
${
router.pathname.match(
sidebarLink.activeRegExp
@ -238,7 +238,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<div className="flex items-center flex-shrink-0 px-4">
<span className="text-2xl text-gray-50">
<a href="/">
<img src="/logo.png" alt="Overseerr Logo" />
<img src="/logo.png" alt="Logo" />
</a>
</span>
</div>
@ -255,7 +255,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
as={sidebarLink.as}
>
<a
className={`group flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150
className={`flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150
${
router.pathname.match(
sidebarLink.activeRegExp

@ -52,10 +52,10 @@ const Layout: React.FC = ({ children }) => {
</div>
<main className="relative z-0 top-16 focus:outline-none" tabIndex={0}>
<div className="pt-2 pb-6">
<div className="pt-2 mb-6">
<div className="px-4 mx-auto max-w-8xl">
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
<div className="p-4 mt-2 bg-indigo-700 rounded-md">
<div className="p-4 mt-6 bg-indigo-700 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg

@ -57,10 +57,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
<>
<Form>
<div className="sm:border-t sm:border-gray-800">
<label
htmlFor="email"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
@ -70,17 +67,13 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
name="email"
type="text"
placeholder="name@example.com"
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.email && touched.email && (
<div className="mt-2 text-red-500">{errors.email}</div>
<div className="error">{errors.email}</div>
)}
</div>
<label
htmlFor="password"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
@ -90,20 +83,19 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.password && touched.password && (
<div className="mt-2 text-red-500">{errors.password}</div>
<div className="error">{errors.password}</div>
)}
</div>
{loginError && (
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-2 text-red-500">{loginError}</div>
<div className="error">{loginError}</div>
</div>
)}
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button

@ -9,11 +9,14 @@ import Transition from '../Transition';
import LanguagePicker from '../Layout/LanguagePicker';
import LocalLogin from './LocalLogin';
import Accordion from '../Common/Accordion';
import useSettings from '../../hooks/useSettings';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
signin: 'Sign In',
signinheader: 'Sign in to continue',
signinwithplex: 'Use your Plex account',
signinwithoverseerr: 'Use your Overseerr account',
signinwithoverseerr: 'Use your {applicationTitle} account',
});
const Login: React.FC = () => {
@ -23,6 +26,7 @@ const Login: React.FC = () => {
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const { user, revalidate } = useUser();
const router = useRouter();
const settings = useSettings();
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to login. If we get a success message, we will
@ -57,6 +61,7 @@ const Login: React.FC = () => {
return (
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
<PageTitle title={intl.formatMessage(messages.signin)} />
<ImageFader
backgroundImages={[
'/images/rotate1.jpg',
@ -71,11 +76,7 @@ const Login: React.FC = () => {
<LanguagePicker />
</div>
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img
src="/logo.png"
className="w-auto mx-auto max-h-32"
alt="Overseerr Logo"
/>
<img src="/logo.png" className="w-auto mx-auto max-h-32" alt="Logo" />
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
<FormattedMessage {...messages.signinheader} />
</h2>
@ -124,10 +125,14 @@ const Login: React.FC = () => {
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`text-sm w-full focus:outline-none transition-colors duration-200 py-2 bg-gray-800 hover:bg-gray-700 bg-opacity-70 hover:bg-opacity-70 sm:rounded-t-lg text-center text-gray-400 ${
className={`w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${
settings.currentSettings.localLogin &&
'hover:bg-gray-700 hover:cursor-pointer'
}`}
onClick={() => handleClick(0)}
disabled={!settings.currentSettings.localLogin}
>
{intl.formatMessage(messages.signinwithplex)}
</button>
@ -139,21 +144,28 @@ const Login: React.FC = () => {
/>
</div>
</AccordionContent>
<button
className={`text-sm w-full focus:outline-none transition-colors duration-200 py-2 bg-gray-800 hover:bg-gray-700 bg-opacity-70 hover:bg-opacity-70 text-center text-gray-400 ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg '
}`}
onClick={() => handleClick(1)}
>
{intl.formatMessage(messages.signinwithoverseerr)}
</button>
<AccordionContent isOpen={openIndexes.includes(1)}>
<div className="px-10 py-8">
<LocalLogin revalidate={revalidate} />
{settings.currentSettings.localLogin && (
<div>
<button
className={`w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 sm:rounded-t-lg hover:bg-gray-700 hover:cursor-pointer ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg '
}`}
onClick={() => handleClick(1)}
>
{intl.formatMessage(messages.signinwithoverseerr, {
applicationTitle:
settings.currentSettings.applicationTitle,
})}
</button>
<AccordionContent isOpen={openIndexes.includes(1)}>
<div className="px-10 py-8">
<LocalLogin revalidate={revalidate} />
</div>
</AccordionContent>
</div>
</AccordionContent>
)}
</>
)}
</Accordion>

@ -9,6 +9,7 @@ import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle';
const messages = defineMessages({
fullcast: 'Full Cast',
@ -32,15 +33,18 @@ const MovieCast: React.FC = () => {
return (
<>
<Header
subtext={
<Link href={`/movie/${data.id}`}>
<a className="hover:underline">{data.title}</a>
</Link>
}
>
{intl.formatMessage(messages.fullcast)}
</Header>
<PageTitle title={[intl.formatMessage(messages.fullcast), data.title]} />
<div className="mt-1 mb-5">
<Header
subtext={
<Link href={`/movie/${data.id}`}>
<a className="hover:underline">{data.title}</a>
</Link>
}
>
{intl.formatMessage(messages.fullcast)}
</Header>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.cast.map((person, index) => {
return (

@ -9,6 +9,7 @@ import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle';
const messages = defineMessages({
fullcrew: 'Full Crew',
@ -32,15 +33,18 @@ const MovieCrew: React.FC = () => {
return (
<>
<Header
subtext={
<Link href={`/movie/${data.id}`}>
<a className="hover:underline">{data.title}</a>
</Link>
}
>
{intl.formatMessage(messages.fullcrew)}
</Header>
<PageTitle title={[intl.formatMessage(messages.fullcrew), data.title]} />
<div className="mt-1 mb-5">
<Header
subtext={
<Link href={`/movie/${data.id}`}>
<a className="hover:underline">{data.title}</a>
</Link>
}
>
{intl.formatMessage(messages.fullcrew)}
</Header>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.crew.map((person, index) => {
return (

@ -9,6 +9,7 @@ import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
recommendations: 'Recommendations',
@ -77,17 +78,22 @@ const MovieRecommendations: React.FC = () => {
return (
<>
<Header
subtext={
movieData && !movieError
? intl.formatMessage(messages.recommendationssubtext, {
title: movieData.title,
})
: ''
}
>
<FormattedMessage {...messages.recommendations} />
</Header>
<PageTitle
title={[intl.formatMessage(messages.recommendations), movieData?.title]}
/>
<div className="mt-1 mb-5">
<Header
subtext={
movieData && !movieError
? intl.formatMessage(messages.recommendationssubtext, {
title: movieData.title,
})
: ''
}
>
<FormattedMessage {...messages.recommendations} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}

@ -9,6 +9,7 @@ import type { MovieDetails } from '../../../server/models/Movie';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { MediaStatus } from '../../../server/constants/media';
import useSettings from '../../hooks/useSettings';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
similar: 'Similar Titles',
@ -77,17 +78,22 @@ const MovieSimilar: React.FC = () => {
return (
<>
<Header
subtext={
movieData && !movieError
? intl.formatMessage(messages.similarsubtext, {
title: movieData.title,
})
: undefined
}
>
<FormattedMessage {...messages.similar} />
</Header>
<PageTitle
title={[intl.formatMessage(messages.similar), movieData?.title]}
/>
<div className="mt-1 mb-5">
<Header
subtext={
movieData && !movieError
? intl.formatMessage(messages.similarsubtext, {
title: movieData.title,
})
: undefined
}
>
<FormattedMessage {...messages.similar} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}

@ -27,7 +27,6 @@ import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes';
import Error from '../../pages/_error';
import Head from 'next/head';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge';
@ -36,6 +35,8 @@ import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import PageTitle from '../Common/PageTitle';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
releasedate: 'Release Date',
@ -81,6 +82,7 @@ interface MovieDetailsProps {
}
const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const settings = useSettings();
const { hasPermission } = useUser();
const router = useRouter();
const intl = useIntl();
@ -137,10 +139,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}}
>
<Head>
<title>{data.title} - Overseerr</title>
</Head>
<PageTitle title={data.title} />
<SlideOver
show={showManager}
title={intl.formatMessage(messages.manageModalTitle)}
@ -163,57 +162,73 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<DownloadBlock downloadItem={status} />
</li>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} is4k />
</li>
))}
</ul>
</div>
</>
)}
{data?.mediaInfo &&
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
data.mediaInfo.status4k !== MediaStatus.AVAILABLE) && (
<div className="flex flex-col mb-6 sm:flex-row flex-nowrap">
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.movie4kEnabled)) && (
<div className="mb-6">
{data?.mediaInfo &&
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<Button
onClick={() => markAvailable()}
className="w-full mb-2 sm:mb-0 sm:mr-1 last:mr-0"
buttonType="success"
>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable()}
className="w-full sm:mb-0"
buttonType="success"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
clipRule="evenodd"
/>
</svg>
<span>{intl.formatMessage(messages.markavailable)}</span>
</Button>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
clipRule="evenodd"
/>
</svg>
<span>{intl.formatMessage(messages.markavailable)}</span>
</Button>
</div>
)}
{data?.mediaInfo &&
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && (
<Button
onClick={() => markAvailable(true)}
className="w-full sm:ml-1 first:ml-0"
buttonType="success"
>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.movie4kEnabled && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable(true)}
className="w-full sm:mb-0"
buttonType="success"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
clipRule="evenodd"
/>
</svg>
<span>{intl.formatMessage(messages.mark4kavailable)}</span>
</Button>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
clipRule="evenodd"
/>
</svg>
<span>
{intl.formatMessage(messages.mark4kavailable)}
</span>
</Button>
</div>
)}
</div>
)}
@ -403,10 +418,17 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
}
}}
>
{data.mediaInfo?.plexUrl ||
(data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))) ? (
{(
trailerUrl
? data.mediaInfo?.plexUrl ||
(data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE)))
: data.mediaInfo?.plexUrl &&
data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))
) ? (
<>
{data.mediaInfo?.plexUrl &&
data.mediaInfo?.plexUrl4k &&
@ -421,17 +443,16 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{intl.formatMessage(messages.play4konplex)}
</ButtonWithDropdown.Item>
)}
{(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) &&
trailerUrl && (
<ButtonWithDropdown.Item
onClick={() => {
window.open(trailerUrl, '_blank');
}}
buttonType="ghost"
>
{intl.formatMessage(messages.watchtrailer)}
</ButtonWithDropdown.Item>
)}
{trailerUrl && (
<ButtonWithDropdown.Item
onClick={() => {
window.open(trailerUrl, '_blank');
}}
buttonType="ghost"
>
{intl.formatMessage(messages.watchtrailer)}
</ButtonWithDropdown.Item>
)}
</>
) : null}
</ButtonWithDropdown>
@ -671,6 +692,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<ExternalLinkBlock
mediaType="movie"
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}

@ -23,12 +23,11 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
: ''
}`}
>
<div className="flex items-center h-5">
<div className="flex items-center h-6">
<input
id={option.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
!!parent?.value && hasNotificationType(parent.value, currentTypes)
}
@ -46,7 +45,7 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
}
/>
</div>
<div className="ml-3 text-sm leading-5">
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="font-medium">
{option.name}
</label>

@ -39,6 +39,8 @@ export const messages = defineMessages({
advancedrequest: 'Advanced Requests',
advancedrequestDescription:
'Grants permission to use advanced request options. (Ex. Changing servers/profiles/paths)',
viewrequests: 'View Requests',
viewrequestsDescription: "Grants permission to view other user's requests.",
});
interface PermissionEditProps {
@ -85,6 +87,12 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
description: intl.formatMessage(messages.advancedrequestDescription),
permission: Permission.REQUEST_ADVANCED,
},
{
id: 'viewrequests',
name: intl.formatMessage(messages.viewrequests),
description: intl.formatMessage(messages.viewrequestsDescription),
permission: Permission.REQUEST_VIEW,
},
],
},
{

@ -41,12 +41,11 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
: ''
}`}
>
<div className="flex items-center h-5">
<div className="flex items-center h-6">
<input
id={option.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
(option.permission !== Permission.ADMIN &&
hasPermission(Permission.ADMIN, currentPermission)) ||
@ -73,15 +72,17 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label htmlFor={option.id} className="font-medium">
{option.name}
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="block font-medium">
<div className="flex flex-col">
<span>{option.name}</span>
<span className="text-gray-500">{option.description}</span>
</div>
</label>
<p className="text-gray-500">{option.description}</p>
</div>
</div>
{(option.children ?? []).map((child) => (
<div key={`permission-child-${child.id}`} className="pl-6 mt-4">
<div key={`permission-child-${child.id}`} className="pl-10 mt-4">
<PermissionOption
option={child}
currentPermission={currentPermission}

@ -38,34 +38,33 @@ const PersonCard: React.FC<PersonCardProps> = ({
className={`relative ${
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
} rounded-lg text-white shadow-lg transition ease-in-out duration-150 cursor-pointer transform-gpu ${
isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100'
isHovered ? 'bg-gray-600 scale-105' : 'bg-gray-700 scale-100'
}`}
>
<div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
{profilePath && (
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
{profilePath ? (
<img
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
alt=""
/>
</div>
)}
{!profilePath && (
<svg
className="mb-6 w-28 h-28 md:w-32 md:h-32"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
clipRule="evenodd"
/>
</svg>
)}
) : (
<svg
className="h-full"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<div className="w-full text-center truncate">{name}</div>
{subName && (
<div
@ -80,7 +79,7 @@ const PersonCard: React.FC<PersonCardProps> = ({
{subName}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-600" />
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-700" />
</div>
</div>
</div>

@ -12,6 +12,7 @@ import { LanguageContext } from '../../context/LanguageContext';
import ImageFader from '../Common/ImageFader';
import Ellipsis from '../../assets/ellipsis.svg';
import { groupBy } from 'lodash';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
appearsin: 'Appears in',
@ -172,6 +173,7 @@ const PersonDetails: React.FC = () => {
return (
<>
<PageTitle title={data.name} />
{(sortedCrew || sortedCast) && (
<div className="absolute top-0 left-0 right-0 z-0 h-96">
<ImageFader

@ -17,7 +17,6 @@ import globalMessages from '../../i18n/globalMessages';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({
requestedby: 'Requested by {username}',
seasons: 'Seasons',
all: 'All',
});
@ -106,10 +105,15 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
{isMovie(title) ? title.title : title.name}
</Link>
</h2>
<div className="text-xs truncate sm:text-sm">
{intl.formatMessage(messages.requestedby, {
username: requestData.requestedBy.displayName,
})}
<div className="flex items-center">
<img
src={requestData.requestedBy.avatar}
alt=""
className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5"
/>
<span className="text-xs truncate sm:text-sm">
{requestData.requestedBy.displayName}
</span>
</div>
{requestData.media.status && (
<div className="mt-1 sm:mt-2">

@ -27,7 +27,6 @@ import { useToasts } from 'react-toast-notifications';
import RequestModal from '../../RequestModal';
const messages = defineMessages({
requestedby: 'Requested by {username}',
seasons: 'Seasons',
notavailable: 'N/A',
failedretry: 'Something went wrong while retrying the request.',
@ -102,7 +101,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title && !error) {
return (
<tr className="w-full h-24 bg-gray-800 animate-pulse" ref={ref}>
<tr className="w-full h-24 animate-pulse" ref={ref}>
<td colSpan={6}></td>
</tr>
);
@ -110,14 +109,14 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title || !requestData) {
return (
<tr className="w-full h-24 bg-gray-800 animate-pulse">
<tr className="w-full h-24 animate-pulse">
<td colSpan={6}></td>
</tr>
);
}
return (
<tr className="relative w-full h-24 p-2 text-white bg-gray-800">
<tr className="relative w-full h-24 p-2">
<RequestModal
show={showEditModal}
tmdbId={request.media.tmdbId}
@ -163,10 +162,15 @@ const RequestItem: React.FC<RequestItemProps> = ({
{isMovie(title) ? title.title : title.name}
</a>
</Link>
<div className="text-sm">
{intl.formatMessage(messages.requestedby, {
username: requestData.requestedBy.displayName,
})}
<div className="flex items-center">
<img
src={requestData.requestedBy.avatar}
alt=""
className="w-5 mr-2 rounded-full"
/>
<span className="text-sm">
{requestData.requestedBy.displayName}
</span>
</div>
{requestData.seasons.length > 0 && (
<div className="items-center hidden mt-2 text-sm sm:flex">
@ -193,7 +197,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
</Badge>
) : (
<StatusBadge
status={requestData.media.status}
status={requestData.media[requestData.is4k ? 'status4k' : 'status']}
inProgress={
(
requestData.media[
@ -201,6 +205,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
] ?? []
).length > 0
}
is4k={requestData.is4k}
/>
)}
</Table.TD>
@ -215,16 +220,24 @@ const RequestItem: React.FC<RequestItemProps> = ({
<div className="flex flex-col">
{requestData.modifiedBy ? (
<span className="text-sm text-gray-300">
{requestData.modifiedBy.displayName}
(
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
)
<div className="flex items-center">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="w-5 mr-2 rounded-full"
/>
<span className="text-sm">
{requestData.modifiedBy.displayName} (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
)
</span>
</div>
</span>
) : (
<span className="text-sm text-gray-300">N/A</span>

@ -7,6 +7,7 @@ import Header from '../Common/Header';
import Table from '../Common/Table';
import Button from '../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
requests: 'Requests',
@ -54,9 +55,10 @@ const RequestList: React.FC = () => {
return (
<>
<PageTitle title={intl.formatMessage(messages.requests)} />
<div className="flex flex-col justify-between md:items-end md:flex-row">
<Header>{intl.formatMessage(messages.requests)}</Header>
<div className="flex flex-col md:flex-row">
<div className="flex flex-col mt-2 md:flex-row">
<div className="flex mb-2 md:mb-0 md:mr-2">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
<svg
@ -84,7 +86,7 @@ const RequestList: React.FC = () => {
setCurrentFilter(e.target.value as Filter);
}}
value={currentFilter}
className="flex-1 block w-full py-2 pl-3 pr-10 text-base leading-6 text-white bg-gray-700 border-gray-500 rounded-r-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
className="rounded-r-only"
>
<option value="all">
{intl.formatMessage(messages.filterAll)}
@ -120,7 +122,7 @@ const RequestList: React.FC = () => {
setCurrentSort(e.target.value as Sort);
}}
value={currentSort}
className="flex-1 block w-full py-2 pl-3 pr-10 text-base leading-6 text-white bg-gray-700 border-gray-500 rounded-r-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
className="rounded-r-only"
>
<option value="added">
{intl.formatMessage(messages.sortAdded)}
@ -134,11 +136,13 @@ const RequestList: React.FC = () => {
</div>
<Table>
<thead>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
<Table.TH></Table.TH>
<tr>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
<Table.TH></Table.TH>
</tr>
</thead>
<Table.TBody>
{data.results.map((request) => {
@ -152,10 +156,12 @@ const RequestList: React.FC = () => {
})}
{data.results.length === 0 && (
<tr className="relative w-full h-24 p-2 text-white bg-gray-800">
<tr className="relative w-full h-24 p-2 text-white">
<Table.TD colSpan={6} noPadding>
<div className="flex flex-col items-center justify-center p-4">
<span>{intl.formatMessage(messages.noresults)}</span>
<div className="flex flex-col items-center justify-center p-6">
<span className="text-base">
{intl.formatMessage(messages.noresults)}
</span>
{currentFilter !== 'all' && (
<div className="mt-4">
<Button
@ -171,10 +177,10 @@ const RequestList: React.FC = () => {
</Table.TD>
</tr>
)}
<tr>
<tr className="bg-gray-700">
<Table.TD colSpan={6} noPadding>
<nav
className="flex items-center justify-between px-4 py-3 text-white bg-gray-700"
className="flex items-center justify-between px-6 py-3"
aria-label="Pagination"
>
<div className="hidden sm:block">

@ -7,18 +7,9 @@ import type {
ServiceCommonServerWithDetails,
} from '../../../../server/interfaces/api/serviceInterfaces';
import { defineMessages, useIntl } from 'react-intl';
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
import { formatBytes } from '../../../utils/numberHelpers';
import { Listbox, Transition } from '@headlessui/react';
import { Permission, User, useUser } from '../../../hooks/useUser';
const messages = defineMessages({
advancedoptions: 'Advanced Options',
@ -29,12 +20,14 @@ const messages = defineMessages({
default: '(Default)',
loadingprofiles: 'Loading profiles…',
loadingfolders: 'Loading folders…',
requestas: 'Request As',
});
export type RequestOverrides = {
server?: number;
profile?: number;
folder?: string;
user?: User;
};
interface AdvancedRequesterProps {
@ -42,6 +35,7 @@ interface AdvancedRequesterProps {
is4k: boolean;
isAnime?: boolean;
defaultOverrides?: RequestOverrides;
requestUser?: User;
onChange: (overrides: RequestOverrides) => void;
}
@ -50,9 +44,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
is4k = false,
isAnime = false,
defaultOverrides,
requestUser,
onChange,
}) => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
{
@ -89,6 +85,22 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
}
);
const [selectedUser, setSelectedUser] = useState<User | null>(
requestUser ?? null
);
const { data: userData } = useSWR<User[]>(
hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS])
? '/api/v1/user'
: null
);
useEffect(() => {
if (userData && !requestUser) {
setSelectedUser(userData.find((u) => u.id === user?.id) ?? null);
}
}, [userData]);
useEffect(() => {
let defaultServer = data?.find(
(server) => server.isDefault && is4k === server.is4k
@ -173,14 +185,15 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
]);
useEffect(() => {
if (selectedServer !== null) {
if (selectedServer !== null || selectedUser) {
onChange({
folder: selectedFolder !== '' ? selectedFolder : undefined,
profile: selectedProfile !== -1 ? selectedProfile : undefined,
server: selectedServer ?? undefined,
user: selectedUser ?? undefined,
});
}
}, [selectedFolder, selectedServer, selectedProfile]);
}, [selectedFolder, selectedServer, selectedProfile, selectedUser]);
if (!data && !error) {
return (
@ -190,7 +203,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
);
}
if (!data || selectedServer === null) {
if ((!data || selectedServer === null) && !selectedUser) {
return null;
}
@ -209,96 +222,229 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
{intl.formatMessage(messages.advancedoptions)}
</div>
<div className="p-4 bg-gray-600 rounded-md shadow">
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0">
<label htmlFor="server" className="block text-sm font-medium">
{intl.formatMessage(messages.destinationserver)}
</label>
<select
id="server"
name="server"
onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(e) => setSelectedServer(Number(e.target.value))}
value={selectedServer}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
{data.map((server) => (
<option key={`server-list-${server.id}`} value={server.id}>
{server.name}
{server.isDefault && server.is4k === is4k
? ` ${intl.formatMessage(messages.default)}`
: ''}
</option>
))}
</select>
</div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0">
<label htmlFor="server" className="block text-sm font-medium">
{intl.formatMessage(messages.qualityprofile)}
</label>
<select
id="profile"
name="profile"
value={selectedProfile}
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
{isValidating && (
<option value="">
{intl.formatMessage(messages.loadingprofiles)}
</option>
)}
{!isValidating &&
serverData &&
serverData.profiles.map((profile) => (
<option key={`profile-list${profile.id}`} value={profile.id}>
{profile.name}
{isAnime &&
serverData.server.activeAnimeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
: !isAnime &&
serverData.server.activeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
: ''}
</option>
))}
</select>
</div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:mb-0">
<label htmlFor="server" className="block text-sm font-medium">
{intl.formatMessage(messages.rootfolder)}
</label>
<select
id="folder"
name="folder"
value={selectedFolder}
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
{isValidating && (
<option value="">
{intl.formatMessage(messages.loadingfolders)}
</option>
)}
{!isValidating &&
serverData &&
serverData.rootFolders.map((folder) => (
<option key={`folder-list${folder.id}`} value={folder.path}>
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
{isAnime &&
serverData.server.activeAnimeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
: !isAnime &&
serverData.server.activeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
: ''}
</option>
))}
</select>
</div>
</div>
{!!data && selectedServer !== null && (
<>
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0">
<label htmlFor="server" className="text-label">
{intl.formatMessage(messages.destinationserver)}
</label>
<select
id="server"
name="server"
value={selectedServer}
onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(e) => setSelectedServer(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
{data.map((server) => (
<option key={`server-list-${server.id}`} value={server.id}>
{server.name}
{server.isDefault && server.is4k === is4k
? ` ${intl.formatMessage(messages.default)}`
: ''}
</option>
))}
</select>
</div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0">
<label htmlFor="server" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
</label>
<select
id="profile"
name="profile"
value={selectedProfile}
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
{isValidating && (
<option value="">
{intl.formatMessage(messages.loadingprofiles)}
</option>
)}
{!isValidating &&
serverData &&
serverData.profiles.map((profile) => (
<option
key={`profile-list${profile.id}`}
value={profile.id}
>
{profile.name}
{isAnime &&
serverData.server.activeAnimeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
: !isAnime &&
serverData.server.activeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
: ''}
</option>
))}
</select>
</div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:mb-0">
<label htmlFor="server" className="text-label">
{intl.formatMessage(messages.rootfolder)}
</label>
<select
id="folder"
name="folder"
value={selectedFolder}
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
{isValidating && (
<option value="">
{intl.formatMessage(messages.loadingfolders)}
</option>
)}
{!isValidating &&
serverData &&
serverData.rootFolders.map((folder) => (
<option
key={`folder-list${folder.id}`}
value={folder.path}
>
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
{isAnime &&
serverData.server.activeAnimeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
: !isAnime &&
serverData.server.activeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
: ''}
</option>
))}
</select>
</div>
</div>
</>
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
<div className="mt-0 sm:mt-2">
<Listbox
as="div"
value={selectedUser}
onChange={(value) => setSelectedUser(value)}
className="space-y-1"
>
{({ open }) => (
<>
<Listbox.Label className="text-label">
{intl.formatMessage(messages.requestas)}
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-800 border border-gray-700 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="flex items-center">
<img
src={selectedUser.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="block ml-3">
{selectedUser.displayName}
</span>
<span className="ml-1 text-gray-400 truncate">
({selectedUser.email})
</span>
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
className="w-5 h-5 text-gray-500"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>
</span>
<Transition
show={open}
enter="transition ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="w-full mt-1 bg-gray-800 rounded-md shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
{userData?.map((user) => (
<Listbox.Option key={user.id} value={user}>
{({ selected, active }) => (
<div
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<img
src={user.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="flex-shrink-0 block ml-3">
{user.displayName}
</span>
<span className="ml-1 text-gray-400 truncate">
({user.email})
</span>
</span>
{selected && (
<span
className={`${
active
? 'text-white'
: 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
</div>
)}
{isAnime && (
<div className="mt-4 italic">
{intl.formatMessage(messages.animenote)}

@ -87,6 +87,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
serverId: requestOverrides.server,
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
@ -169,6 +170,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
});
addToast(<span>{intl.formatMessage(messages.requestedited)}</span>, {
@ -227,11 +229,13 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
username: activeRequest.requestedBy.displayName,
}
)}
{hasPermission(Permission.REQUEST_ADVANCED) && (
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<div className="mt-4">
<AdvancedRequester
type="movie"
is4k={is4k}
requestUser={editRequest?.requestedBy}
defaultOverrides={
editRequest
? {
@ -279,7 +283,8 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
</Alert>
</p>
)}
{hasPermission(Permission.REQUEST_ADVANCED) && (
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="movie"
is4k={is4k}

@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import useSWR from 'swr';
import { useToasts } from 'react-toast-notifications';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
import axios from 'axios';
import {
MediaStatus,
@ -103,6 +103,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
seasons: selectedSeasons,
});
} else {
@ -150,6 +151,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
serverId: requestOverrides.server,
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides?.user?.id,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
@ -391,7 +393,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleAllSeasons();
}
}}
className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 cursor-pointer group focus:outline-none"
className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none"
>
<span
aria-hidden="true"
@ -451,7 +453,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleSeason(season.seasonNumber);
}
}}
className={`group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
mediaSeason ||
(!!seasonRequest &&
!editingSeasons.includes(season.seasonNumber))
@ -550,7 +552,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
</div>
</div>
</div>
{hasPermission(Permission.REQUEST_ADVANCED) && (
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<div className="mt-4">
<AdvancedRequester
type="tv"
@ -559,6 +562,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
(keyword) => keyword.id === ANIME_KEYWORD_ID
)}
onChange={(overrides) => setRequestOverrides(overrides)}
requestUser={editRequest?.requestedBy}
defaultOverrides={
editRequest
? {

@ -10,8 +10,10 @@ import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
search: 'Search',
searchresults: 'Search Results',
});
@ -65,7 +67,10 @@ const Search: React.FC = () => {
return (
<>
<Header>{intl.formatMessage(messages.searchresults)}</Header>
<PageTitle title={intl.formatMessage(messages.search)} />
<div className="mt-1 mb-5">
<Header>{intl.formatMessage(messages.searchresults)}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}

@ -29,7 +29,7 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => {
e.preventDefault();
setCopied();
}}
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
<svg
className="w-5 h-5 text-white"

@ -14,13 +14,13 @@ const messages = defineMessages({
saving: 'Saving…',
agentenabled: 'Enable Agent',
webhookUrl: 'Webhook URL',
validationWebhookUrlRequired: 'You must provide a webhook URL',
webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks',
discordsettingssaved: 'Discord notification settings saved!',
discordsettingsfailed: 'Discord notification settings failed to save.',
testsent: 'Test notification sent!',
test: 'Test',
notificationtypes: 'Notification Types',
validationWebhookUrl: 'You must provide a valid URL',
});
const NotificationsDiscord: React.FC = () => {
@ -31,9 +31,9 @@ const NotificationsDiscord: React.FC = () => {
);
const NotificationsDiscordSchema = Yup.object().shape({
webhookUrl: Yup.string().required(
intl.formatMessage(messages.validationWebhookUrlRequired)
),
webhookUrl: Yup.string()
.required(intl.formatMessage(messages.validationWebhookUrl))
.url(intl.formatMessage(messages.validationWebhookUrl)),
});
if (!data && !error) {
@ -88,31 +88,20 @@ const NotificationsDiscord: React.FC = () => {
};
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
{intl.formatMessage(messages.agentenabled)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="enabled"
name="enabled"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
<div className="form-input">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.webhookUrl)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="form-input">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="webhookUrl"
@ -121,39 +110,29 @@ const NotificationsDiscord: React.FC = () => {
placeholder={intl.formatMessage(
messages.webhookUrlPlaceholder
)}
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.webhookUrl && touched.webhookUrl && (
<div className="mt-2 text-red-500">{errors.webhookUrl}</div>
<div className="error">{errors.webhookUrl}</div>
)}
</div>
</div>
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
<div role="group" aria-labelledby="group-label" className="group">
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationtypes)}
</span>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save