Merge branch 'develop'

This commit is contained in:
sct
2021-02-05 10:25:43 +00:00
146 changed files with 5042 additions and 3784 deletions

View File

@@ -292,6 +292,15 @@
"contributions": [ "contributions": [
"code" "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>", "badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

View File

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

View File

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

23
.github/workflows/deploy_docs.yml vendored Normal file
View File

@@ -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

9
.stoplight.json Normal file
View File

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

View File

@@ -1,4 +1,4 @@
FROM node:12.18-alpine AS BUILD_IMAGE FROM node:14.15-alpine AS BUILD_IMAGE
ARG COMMIT_TAG ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG} ENV COMMIT_TAG=${COMMIT_TAG}
@@ -11,25 +11,23 @@ RUN yarn --frozen-lockfile && \
# remove development dependencies # remove development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline 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 RUN touch config/DOCKER
ENV COMMIT_TAG=${COMMIT_TAG}
RUN apk add tzdata
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
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:14.15-alpine
RUN apk add --no-cache tzdata
# copy from build image
COPY --from=BUILD_IMAGE /app /app
WORKDIR /app
CMD yarn start CMD yarn start
EXPOSE 5055 EXPOSE 5055

View File

@@ -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> <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"> <img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- 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 --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
</p> </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 ## Current Features
- Full Plex integration. Login and manage user access with Plex! - 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! - Easy integration 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. - 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. - 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. - Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system - Granular permission system.
- Mobile friendly design, for when you need to approve requests on the go! - Mobile-friendly design, for when you need to approve requests on the go!
## In Development ## In Development
@@ -46,19 +46,18 @@
## Getting Started ## 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 https://docs.overseerr.dev/getting-started/installation
## Running Overseerr ## 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 \ docker run -d \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \ -e TZ=Asia/Tokyo \
-e PROXY=<yes|no>
-p 5055:5055 \ -p 5055:5055 \
-v /path/to/appdata/config:/app/config \ -v /path/to/appdata/config:/app/config \
--restart unless-stopped \ --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. 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 ## 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). - 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 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). - 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 ## 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 ## 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/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://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://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> </tr>
</table> </table>

View File

@@ -115,7 +115,7 @@ server {
# HTTP Strict Transport Security # HTTP Strict Transport Security
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary # 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) # Prevent some categories of XSS attacks (X-XSS-Protection)
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
# Provide clickjacking protection (X-Frame-Options) # Provide clickjacking protection (X-Frame-Options)

View File

@@ -2,8 +2,43 @@ openapi: '3.0.2'
info: info:
title: 'Overseerr API' title: 'Overseerr API'
version: '1.0.0' 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: servers:
- url: /api/v1 - url: '{server}/api/v1'
variables:
server:
default: http://localhost:5055
components: components:
schemas: schemas:
@@ -59,6 +94,9 @@ components:
type: string type: string
example: 'anapikey' example: 'anapikey'
readOnly: true readOnly: true
applicationTitle:
type: string
example: Overseerr
applicationUrl: applicationUrl:
type: string type: string
example: https://os.example.com example: https://os.example.com
@@ -71,6 +109,9 @@ components:
hideAvailable: hideAvailable:
type: boolean type: boolean
example: false example: false
localLogin:
type: boolean
example: true
defaultPermissions: defaultPermissions:
type: number type: number
example: 32 example: 32
@@ -116,17 +157,6 @@ components:
- machineId - machineId
- ip - ip
- port - port
PlexStatus:
type: object
properties:
settings:
$ref: '#/components/schemas/PlexSettings'
status:
type: number
example: 200
message:
type: string
example: 'OK'
PlexConnection: PlexConnection:
type: object type: object
properties: properties:
@@ -391,29 +421,6 @@ components:
initialized: initialized:
type: boolean type: boolean
example: false 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: MovieResult:
type: object type: object
required: required:
@@ -587,7 +594,7 @@ components:
readOnly: true readOnly: true
imdbId: imdbId:
type: string type: string
example: 123 example: 'tt123'
adult: adult:
type: boolean type: boolean
backdropPath: backdropPath:
@@ -1470,6 +1477,27 @@ paths:
example: 1.0.0 example: 1.0.0
commitTag: commitTag:
type: string 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: /settings/main:
get: get:
summary: Get main settings summary: Get main settings
@@ -2024,6 +2052,56 @@ paths:
running: running:
type: boolean type: boolean
example: false 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: /settings/notifications:
get: get:
summary: Return notification settings summary: Return notification settings
@@ -2504,6 +2582,7 @@ paths:
application/json: application/json:
schema: schema:
type: array type: array
items:
$ref: '#/components/schemas/User' $ref: '#/components/schemas/User'
/user/import-from-plex: /user/import-from-plex:
@@ -2971,7 +3050,7 @@ paths:
name: requestId name: requestId
description: Request ID description: Request ID
required: true required: true
example: 1 example: '1'
schema: schema:
type: string type: string
responses: responses:
@@ -2991,7 +3070,7 @@ paths:
name: requestId name: requestId
description: Request ID description: Request ID
required: true required: true
example: 1 example: '1'
schema: schema:
type: string type: string
responses: responses:
@@ -3011,7 +3090,7 @@ paths:
name: requestId name: requestId
description: Request ID description: Request ID
required: true required: true
example: 1 example: '1'
schema: schema:
type: string type: string
responses: responses:
@@ -3033,7 +3112,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
example: 1 example: '1'
responses: responses:
'200': '200':
description: Retry triggered description: Retry triggered
@@ -3057,7 +3136,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
example: 1 example: '1'
- in: path - in: path
name: status name: status
description: New status description: New status
@@ -3529,7 +3608,7 @@ paths:
name: mediaId name: mediaId
description: Media ID description: Media ID
required: true required: true
example: 1 example: '1'
schema: schema:
type: string type: string
responses: responses:
@@ -3546,7 +3625,7 @@ paths:
name: mediaId name: mediaId
description: Media ID description: Media ID
required: true required: true
example: 1 example: '1'
schema: schema:
type: string type: string
- in: path - in: path

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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 // originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
const MAPPING_URL = const MAPPING_URL =
'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml'; '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); const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);

102
server/api/externalapi.ts Normal file
View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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[];
}

View File

@@ -15,7 +15,8 @@ import { User } from './User';
import Media from './Media'; import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings'; 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 RadarrAPI from '../api/radarr';
import logger from '../logger'; import logger from '../logger';
import SeasonRequest from './SeasonRequest'; import SeasonRequest from './SeasonRequest';
@@ -414,6 +415,15 @@ export class MediaRequest {
searchNow: !radarrSettings.preventSearch, searchNow: !radarrSettings.preventSearch,
}) })
.then(async (radarrMovie) => { .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'] = media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id; radarrMovie.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =

View File

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

View File

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

View File

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

View File

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

60
server/lib/cache.ts Normal file
View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,7 @@ class TelegramAgent
if (settings.main.applicationUrl && payload.media) { if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; 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 */ /* eslint-enable */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { import type {
TmdbCreditCast, TmdbCreditCast,
TmdbAggregateCreditCast,
TmdbCreditCrew, TmdbCreditCrew,
TmdbExternalIds, TmdbExternalIds,
TmdbVideo, TmdbVideo,
TmdbVideoResult, TmdbVideoResult,
} from '../api/themoviedb'; } from '../api/themoviedb/interfaces';
import { Video } from '../models/Movie'; import { Video } from '../models/Movie';
@@ -68,6 +69,18 @@ export const mapCast = (person: TmdbCreditCast): Cast => ({
profilePath: person.profile_path, 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 => ({ export const mapCrew = (person: TmdbCreditCrew): Crew => ({
creditId: person.credit_id, creditId: person.credit_id,
department: person.department, department: person.department,

View File

@@ -134,10 +134,13 @@ authRoutes.post('/login', async (req, res, next) => {
}); });
authRoutes.post('/local', async (req, res, next) => { authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User); const userRepository = getRepository(User);
const body = req.body as { email?: string; password?: string }; 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 return res
.status(500) .status(500)
.json({ error: 'You must provide an email and a password' }); .json({ error: 'You must provide an email and a password' });

View File

@@ -15,6 +15,7 @@ import personRoutes from './person';
import collectionRoutes from './collection'; import collectionRoutes from './collection';
import { getAppVersion, getCommitTag } from '../utils/appVersion'; import { getAppVersion, getCommitTag } from '../utils/appVersion';
import serviceRoutes from './service'; import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
const router = Router(); 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.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
router.get('/settings/public', (_req, res) => { router.get('/settings/public', (_req, res) => {
const settings = getSettings(); const settings = getSettings();

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces';
import notificationRoutes from './notifications'; import notificationRoutes from './notifications';
import sonarrRoutes from './sonarr'; import sonarrRoutes from './sonarr';
import radarrRoutes from './radarr'; import radarrRoutes from './radarr';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
const settingsRoutes = Router(); 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( settingsRoutes.get(
'/initialize', '/initialize',
isAuthenticated(Permission.ADMIN), isAuthenticated(Permission.ADMIN),

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,6 @@ import type { User } from '../entity/User';
declare global { declare global {
namespace Express { namespace Express {
export interface Session {
userId?: number;
}
export interface Request { export interface Request {
user?: User; user?: User;
} }
@@ -19,3 +15,11 @@ declare global {
next: NextFunction next: NextFunction
) => Promise<void | NextFunction> | void | 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;
}
}

View File

@@ -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;
};

View File

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

View File

@@ -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

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
import axios from 'axios'; import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext, useState } from 'react'; import React, { useContext, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@@ -18,6 +17,7 @@ import Modal from '../Common/Modal';
import Slider from '../Slider'; import Slider from '../Slider';
import TitleCard from '../TitleCard'; import TitleCard from '../TitleCard';
import Transition from '../Transition'; import Transition from '../Transition';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
overviewunavailable: 'Overview unavailable.', 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})`, 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> <PageTitle title={data.name} />
<title>{data.name} - Overseerr</title>
</Head>
<Transition <Transition
enter="opacity-0 transition duration-300" enter="opacity-0 transition duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"

View File

@@ -77,7 +77,7 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
} }
return ( 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">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div> <div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3"> <div className="ml-3">

View File

@@ -92,8 +92,8 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
> >
{text} {text}
</button> </button>
<span className="relative z-10 block -ml-px">
{children && ( {children && (
<span className="relative z-10 block -ml-px">
<button <button
type="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}`} 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,7 +117,6 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
</svg> </svg>
)} )}
</button> </button>
)}
<Transition <Transition
show={isOpen} show={isOpen}
enter="transition ease-out duration-100 opacity-0" enter="transition ease-out duration-100 opacity-0"
@@ -136,6 +135,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
</div> </div>
</Transition> </Transition>
</span> </span>
)}
</span> </span>
); );
}; };

View File

@@ -11,14 +11,14 @@ const Header: React.FC<HeaderProps> = ({
subtext, subtext,
}) => { }) => {
return ( 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}`}> <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"> <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="bg-clip-text text-transparent bg-gradient-to-br from-indigo-400 to-purple-400"> <span className="text-transparent bg-clip-text bg-gradient-to-br from-indigo-400 to-purple-400">
{children} {children}
</span> </span>
</h2> </h2>
{subtext && <div className="text-gray-400 mt-2">{subtext}</div>} {subtext && <div className="mt-2 text-gray-400">{subtext}</div>}
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -112,7 +112,7 @@ const Modal: React.FC<ModalProps> = ({
)} )}
<div <div
className={`mt-3 text-center sm:mt-0 sm:text-left ${ 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 && ( {title && (

View File

@@ -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;

View File

@@ -3,7 +3,7 @@ import { withProperties } from '../../../utils/typeHelpers';
const TBody: React.FC = ({ children }) => { const TBody: React.FC = ({ children }) => {
return ( 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 }) => { const Table: React.FC = ({ children }) => {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="my-2 overflow-x-auto -mx-6 md:mx-0 lg:mx-0"> <div className="my-2 -mx-4 overflow-x-auto md:mx-0 lg:mx-0">
<div className="py-2 align-middle inline-block min-w-full"> <div className="inline-block min-w-full py-2 align-middle">
<div className="shadow overflow-hidden sm:rounded-lg"> <div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">{children}</table> <table className="min-w-full">{children}</table>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,61 +17,65 @@ type AvailableLanguageObject = Record<
>; >;
const availableLanguages: AvailableLanguageObject = { const availableLanguages: AvailableLanguageObject = {
de: {
code: 'de',
display: 'Deutsch',
},
en: { en: {
code: 'en', code: 'en',
display: 'English', display: 'English',
}, },
ja: { es: {
code: 'ja', code: 'es',
display: 'Japanese', display: 'Español',
}, },
fr: { fr: {
code: 'fr', code: 'fr',
display: 'Français', display: 'Français',
}, },
'nb-NO': { it: {
code: 'nb-NO', code: 'it',
display: 'Norwegian Bokmål', display: 'Italiano',
}, },
de: { hu: {
code: 'de', code: 'hu',
display: 'German', display: 'Magyar',
},
ru: {
code: 'ru',
display: 'Russian',
}, },
nl: { nl: {
code: 'nl', code: 'nl',
display: 'Nederlands', display: 'Nederlands',
}, },
es: { 'nb-NO': {
code: 'es', code: 'nb-NO',
display: 'Spanish', display: 'Norsk Bokmål',
},
it: {
code: 'it',
display: 'Italian',
}, },
'pt-BR': { 'pt-BR': {
code: 'pt-BR', code: 'pt-BR',
display: 'Portuguese (Brazil)', display: 'Português (Brasil)',
}, },
'pt-PT': { 'pt-PT': {
code: 'pt-PT', code: 'pt-PT',
display: 'Portuguese (Portugal)', display: 'Português (Portugal)',
},
sr: {
code: 'sr',
display: 'Serbian',
}, },
sv: { sv: {
code: 'sv', code: 'sv',
display: 'Swedish', display: 'Svenska',
}, },
'zh-Hant': { ru: {
code: 'zh-Hant', code: 'ru',
display: 'Chinese (Traditional)', display: 'pусский',
},
sr: {
code: 'sr',
display: 'српски језик‬',
},
ja: {
code: 'ja',
display: '日本語',
},
'zh-TW': {
code: 'zh-TW',
display: '中文(臺灣)',
}, },
}; };
@@ -113,10 +117,10 @@ const LanguagePicker: React.FC = () => {
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<div <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} 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> <div>
<label <label
htmlFor="language" htmlFor="language"
@@ -126,7 +130,7 @@ const LanguagePicker: React.FC = () => {
</label> </label>
<select <select
id="language" 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) => onChange={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales) setLocale && setLocale(e.target.value as AvailableLocales)
} }

View File

@@ -176,7 +176,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<div className="flex-shrink-0 flex items-center px-4"> <div className="flex-shrink-0 flex items-center px-4">
<span className="text-xl text-gray-50"> <span className="text-xl text-gray-50">
<a href="/"> <a href="/">
<img src="/logo.png" alt="Overseerr Logo" /> <img src="/logo.png" alt="Logo" />
</a> </a>
</span> </span>
</div> </div>
@@ -201,7 +201,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
}} }}
role="button" role="button"
tabIndex={0} 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( router.pathname.match(
sidebarLink.activeRegExp sidebarLink.activeRegExp
@@ -238,7 +238,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<div className="flex items-center flex-shrink-0 px-4"> <div className="flex items-center flex-shrink-0 px-4">
<span className="text-2xl text-gray-50"> <span className="text-2xl text-gray-50">
<a href="/"> <a href="/">
<img src="/logo.png" alt="Overseerr Logo" /> <img src="/logo.png" alt="Logo" />
</a> </a>
</span> </span>
</div> </div>
@@ -255,7 +255,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
as={sidebarLink.as} as={sidebarLink.as}
> >
<a <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( router.pathname.match(
sidebarLink.activeRegExp sidebarLink.activeRegExp

View File

@@ -52,10 +52,10 @@ const Layout: React.FC = ({ children }) => {
</div> </div>
<main className="relative z-0 top-16 focus:outline-none" tabIndex={0}> <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"> <div className="px-4 mx-auto max-w-8xl">
{router.pathname === '/' && hasPermission(Permission.ADMIN) && ( {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">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg <svg

View File

@@ -57,10 +57,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
<> <>
<Form> <Form>
<div className="sm:border-t sm:border-gray-800"> <div className="sm:border-t sm:border-gray-800">
<label <label htmlFor="email" className="text-label">
htmlFor="email"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.email)} {intl.formatMessage(messages.email)}
</label> </label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2"> <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" name="email"
type="text" type="text"
placeholder="name@example.com" 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> </div>
{errors.email && touched.email && ( {errors.email && touched.email && (
<div className="mt-2 text-red-500">{errors.email}</div> <div className="error">{errors.email}</div>
)} )}
</div> </div>
<label <label htmlFor="password" className="text-label">
htmlFor="password"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.password)} {intl.formatMessage(messages.password)}
</label> </label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2"> <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" name="password"
type="password" type="password"
placeholder={intl.formatMessage(messages.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> </div>
{errors.password && touched.password && ( {errors.password && touched.password && (
<div className="mt-2 text-red-500">{errors.password}</div> <div className="error">{errors.password}</div>
)} )}
</div> </div>
{loginError && ( {loginError && (
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2"> <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> </div>
<div className="pt-5 mt-8 border-t border-gray-700"> <div className="actions">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm"> <span className="inline-flex ml-3 rounded-md shadow-sm">
<Button <Button

View File

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

View File

@@ -9,6 +9,7 @@ import Error from '../../../pages/_error';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard'; import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
fullcast: 'Full Cast', fullcast: 'Full Cast',
@@ -32,6 +33,8 @@ const MovieCast: React.FC = () => {
return ( return (
<> <>
<PageTitle title={[intl.formatMessage(messages.fullcast), data.title]} />
<div className="mt-1 mb-5">
<Header <Header
subtext={ subtext={
<Link href={`/movie/${data.id}`}> <Link href={`/movie/${data.id}`}>
@@ -41,6 +44,7 @@ const MovieCast: React.FC = () => {
> >
{intl.formatMessage(messages.fullcast)} {intl.formatMessage(messages.fullcast)}
</Header> </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"> <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) => { {data?.credits.cast.map((person, index) => {
return ( return (

View File

@@ -9,6 +9,7 @@ import Error from '../../../pages/_error';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard'; import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
fullcrew: 'Full Crew', fullcrew: 'Full Crew',
@@ -32,6 +33,8 @@ const MovieCrew: React.FC = () => {
return ( return (
<> <>
<PageTitle title={[intl.formatMessage(messages.fullcrew), data.title]} />
<div className="mt-1 mb-5">
<Header <Header
subtext={ subtext={
<Link href={`/movie/${data.id}`}> <Link href={`/movie/${data.id}`}>
@@ -41,6 +44,7 @@ const MovieCrew: React.FC = () => {
> >
{intl.formatMessage(messages.fullcrew)} {intl.formatMessage(messages.fullcrew)}
</Header> </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"> <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) => { {data?.credits.crew.map((person, index) => {
return ( return (

View File

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

View File

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

View File

@@ -27,7 +27,6 @@ import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg'; import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes'; import type { RTRating } from '../../../server/api/rottentomatoes';
import Error from '../../pages/_error'; import Error from '../../pages/_error';
import Head from 'next/head';
import ExternalLinkBlock from '../ExternalLinkBlock'; import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers'; import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
@@ -36,6 +35,8 @@ import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton'; import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock'; import DownloadBlock from '../DownloadBlock';
import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import PageTitle from '../Common/PageTitle';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({ const messages = defineMessages({
releasedate: 'Release Date', releasedate: 'Release Date',
@@ -81,6 +82,7 @@ interface MovieDetailsProps {
} }
const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => { const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const settings = useSettings();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const router = useRouter(); const router = useRouter();
const intl = useIntl(); 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})`, 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> <PageTitle title={data.title} />
<title>{data.title} - Overseerr</title>
</Head>
<SlideOver <SlideOver
show={showManager} show={showManager}
title={intl.formatMessage(messages.manageModalTitle)} title={intl.formatMessage(messages.manageModalTitle)}
@@ -163,19 +162,29 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<DownloadBlock downloadItem={status} /> <DownloadBlock downloadItem={status} />
</li> </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> </ul>
</div> </div>
</> </>
)} )}
{data?.mediaInfo && {data?.mediaInfo &&
(data.mediaInfo.status !== MediaStatus.AVAILABLE || (data.mediaInfo.status !== MediaStatus.AVAILABLE ||
data.mediaInfo.status4k !== MediaStatus.AVAILABLE) && ( (data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
<div className="flex flex-col mb-6 sm:flex-row flex-nowrap"> settings.currentSettings.movie4kEnabled)) && (
<div className="mb-6">
{data?.mediaInfo && {data?.mediaInfo &&
data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button <Button
onClick={() => markAvailable()} onClick={() => markAvailable()}
className="w-full mb-2 sm:mb-0 sm:mr-1 last:mr-0" className="w-full sm:mb-0"
buttonType="success" buttonType="success"
> >
<svg <svg
@@ -192,12 +201,15 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</svg> </svg>
<span>{intl.formatMessage(messages.markavailable)}</span> <span>{intl.formatMessage(messages.markavailable)}</span>
</Button> </Button>
</div>
)} )}
{data?.mediaInfo && {data?.mediaInfo &&
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && ( data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.movie4kEnabled && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button <Button
onClick={() => markAvailable(true)} onClick={() => markAvailable(true)}
className="w-full sm:ml-1 first:ml-0" className="w-full sm:mb-0"
buttonType="success" buttonType="success"
> >
<svg <svg
@@ -212,8 +224,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
<span>{intl.formatMessage(messages.mark4kavailable)}</span> <span>
{intl.formatMessage(messages.mark4kavailable)}
</span>
</Button> </Button>
</div>
)} )}
</div> </div>
)} )}
@@ -403,10 +418,17 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
} }
}} }}
> >
{data.mediaInfo?.plexUrl || {(
trailerUrl
? data.mediaInfo?.plexUrl ||
(data.mediaInfo?.plexUrl4k && (data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) || (hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))) ? ( 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?.plexUrl &&
data.mediaInfo?.plexUrl4k && data.mediaInfo?.plexUrl4k &&
@@ -421,8 +443,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{intl.formatMessage(messages.play4konplex)} {intl.formatMessage(messages.play4konplex)}
</ButtonWithDropdown.Item> </ButtonWithDropdown.Item>
)} )}
{(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) && {trailerUrl && (
trailerUrl && (
<ButtonWithDropdown.Item <ButtonWithDropdown.Item
onClick={() => { onClick={() => {
window.open(trailerUrl, '_blank'); window.open(trailerUrl, '_blank');
@@ -671,6 +692,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<ExternalLinkBlock <ExternalLinkBlock
mediaType="movie" mediaType="movie"
tmdbId={data.id} tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId} imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url} rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k} plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}

View File

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

View File

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

View File

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

View File

@@ -38,23 +38,21 @@ const PersonCard: React.FC<PersonCardProps> = ({
className={`relative ${ className={`relative ${
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44' 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 ${ } 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 style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2"> <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 <img
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`} 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" className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
alt="" alt=""
/> />
</div> ) : (
)}
{!profilePath && (
<svg <svg
className="mb-6 w-28 h-28 md:w-32 md:h-32" className="h-full"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -66,6 +64,7 @@ const PersonCard: React.FC<PersonCardProps> = ({
/> />
</svg> </svg>
)} )}
</div>
<div className="w-full text-center truncate">{name}</div> <div className="w-full text-center truncate">{name}</div>
{subName && ( {subName && (
<div <div
@@ -80,7 +79,7 @@ const PersonCard: React.FC<PersonCardProps> = ({
{subName} {subName}
</div> </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> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import Header from '../Common/Header';
import Table from '../Common/Table'; import Table from '../Common/Table';
import Button from '../Common/Button'; import Button from '../Common/Button';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
requests: 'Requests', requests: 'Requests',
@@ -54,9 +55,10 @@ const RequestList: React.FC = () => {
return ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.requests)} />
<div className="flex flex-col justify-between md:items-end md:flex-row"> <div className="flex flex-col justify-between md:items-end md:flex-row">
<Header>{intl.formatMessage(messages.requests)}</Header> <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"> <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"> <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 <svg
@@ -84,7 +86,7 @@ const RequestList: React.FC = () => {
setCurrentFilter(e.target.value as Filter); setCurrentFilter(e.target.value as Filter);
}} }}
value={currentFilter} 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"> <option value="all">
{intl.formatMessage(messages.filterAll)} {intl.formatMessage(messages.filterAll)}
@@ -120,7 +122,7 @@ const RequestList: React.FC = () => {
setCurrentSort(e.target.value as Sort); setCurrentSort(e.target.value as Sort);
}} }}
value={currentSort} 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"> <option value="added">
{intl.formatMessage(messages.sortAdded)} {intl.formatMessage(messages.sortAdded)}
@@ -134,11 +136,13 @@ const RequestList: React.FC = () => {
</div> </div>
<Table> <Table>
<thead> <thead>
<tr>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH> <Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH> <Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH> <Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH> <Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
<Table.TH></Table.TH> <Table.TH></Table.TH>
</tr>
</thead> </thead>
<Table.TBody> <Table.TBody>
{data.results.map((request) => { {data.results.map((request) => {
@@ -152,10 +156,12 @@ const RequestList: React.FC = () => {
})} })}
{data.results.length === 0 && ( {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> <Table.TD colSpan={6} noPadding>
<div className="flex flex-col items-center justify-center p-4"> <div className="flex flex-col items-center justify-center p-6">
<span>{intl.formatMessage(messages.noresults)}</span> <span className="text-base">
{intl.formatMessage(messages.noresults)}
</span>
{currentFilter !== 'all' && ( {currentFilter !== 'all' && (
<div className="mt-4"> <div className="mt-4">
<Button <Button
@@ -171,10 +177,10 @@ const RequestList: React.FC = () => {
</Table.TD> </Table.TD>
</tr> </tr>
)} )}
<tr> <tr className="bg-gray-700">
<Table.TD colSpan={6} noPadding> <Table.TD colSpan={6} noPadding>
<nav <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" aria-label="Pagination"
> >
<div className="hidden sm:block"> <div className="hidden sm:block">

View File

@@ -7,18 +7,9 @@ import type {
ServiceCommonServerWithDetails, ServiceCommonServerWithDetails,
} from '../../../../server/interfaces/api/serviceInterfaces'; } from '../../../../server/interfaces/api/serviceInterfaces';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { formatBytes } from '../../../utils/numberHelpers';
const formatBytes = (bytes: number, decimals = 2) => { import { Listbox, Transition } from '@headlessui/react';
if (bytes === 0) return '0 Bytes'; import { Permission, User, useUser } from '../../../hooks/useUser';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const messages = defineMessages({ const messages = defineMessages({
advancedoptions: 'Advanced Options', advancedoptions: 'Advanced Options',
@@ -29,12 +20,14 @@ const messages = defineMessages({
default: '(Default)', default: '(Default)',
loadingprofiles: 'Loading profiles…', loadingprofiles: 'Loading profiles…',
loadingfolders: 'Loading folders…', loadingfolders: 'Loading folders…',
requestas: 'Request As',
}); });
export type RequestOverrides = { export type RequestOverrides = {
server?: number; server?: number;
profile?: number; profile?: number;
folder?: string; folder?: string;
user?: User;
}; };
interface AdvancedRequesterProps { interface AdvancedRequesterProps {
@@ -42,6 +35,7 @@ interface AdvancedRequesterProps {
is4k: boolean; is4k: boolean;
isAnime?: boolean; isAnime?: boolean;
defaultOverrides?: RequestOverrides; defaultOverrides?: RequestOverrides;
requestUser?: User;
onChange: (overrides: RequestOverrides) => void; onChange: (overrides: RequestOverrides) => void;
} }
@@ -50,9 +44,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
is4k = false, is4k = false,
isAnime = false, isAnime = false,
defaultOverrides, defaultOverrides,
requestUser,
onChange, onChange,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { user, hasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>( const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`, `/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(() => { useEffect(() => {
let defaultServer = data?.find( let defaultServer = data?.find(
(server) => server.isDefault && is4k === server.is4k (server) => server.isDefault && is4k === server.is4k
@@ -173,14 +185,15 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
]); ]);
useEffect(() => { useEffect(() => {
if (selectedServer !== null) { if (selectedServer !== null || selectedUser) {
onChange({ onChange({
folder: selectedFolder !== '' ? selectedFolder : undefined, folder: selectedFolder !== '' ? selectedFolder : undefined,
profile: selectedProfile !== -1 ? selectedProfile : undefined, profile: selectedProfile !== -1 ? selectedProfile : undefined,
server: selectedServer ?? undefined, server: selectedServer ?? undefined,
user: selectedUser ?? undefined,
}); });
} }
}, [selectedFolder, selectedServer, selectedProfile]); }, [selectedFolder, selectedServer, selectedProfile, selectedUser]);
if (!data && !error) { if (!data && !error) {
return ( return (
@@ -190,7 +203,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
); );
} }
if (!data || selectedServer === null) { if ((!data || selectedServer === null) && !selectedUser) {
return null; return null;
} }
@@ -209,17 +222,19 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
{intl.formatMessage(messages.advancedoptions)} {intl.formatMessage(messages.advancedoptions)}
</div> </div>
<div className="p-4 bg-gray-600 rounded-md shadow"> <div className="p-4 bg-gray-600 rounded-md shadow">
{!!data && selectedServer !== null && (
<>
<div className="flex flex-col items-center justify-between md:flex-row"> <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"> <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"> <label htmlFor="server" className="text-label">
{intl.formatMessage(messages.destinationserver)} {intl.formatMessage(messages.destinationserver)}
</label> </label>
<select <select
id="server" id="server"
name="server" name="server"
value={selectedServer}
onChange={(e) => setSelectedServer(Number(e.target.value))} onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(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" 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) => ( {data.map((server) => (
@@ -233,7 +248,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</select> </select>
</div> </div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0"> <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"> <label htmlFor="server" className="text-label">
{intl.formatMessage(messages.qualityprofile)} {intl.formatMessage(messages.qualityprofile)}
</label> </label>
<select <select
@@ -252,7 +267,10 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
{!isValidating && {!isValidating &&
serverData && serverData &&
serverData.profiles.map((profile) => ( serverData.profiles.map((profile) => (
<option key={`profile-list${profile.id}`} value={profile.id}> <option
key={`profile-list${profile.id}`}
value={profile.id}
>
{profile.name} {profile.name}
{isAnime && {isAnime &&
serverData.server.activeAnimeProfileId === profile.id serverData.server.activeAnimeProfileId === profile.id
@@ -266,7 +284,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</select> </select>
</div> </div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:mb-0"> <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"> <label htmlFor="server" className="text-label">
{intl.formatMessage(messages.rootfolder)} {intl.formatMessage(messages.rootfolder)}
</label> </label>
<select <select
@@ -285,7 +303,10 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
{!isValidating && {!isValidating &&
serverData && serverData &&
serverData.rootFolders.map((folder) => ( serverData.rootFolders.map((folder) => (
<option key={`folder-list${folder.id}`} value={folder.path}> <option
key={`folder-list${folder.id}`}
value={folder.path}
>
{folder.path} ({formatBytes(folder.freeSpace ?? 0)}) {folder.path} ({formatBytes(folder.freeSpace ?? 0)})
{isAnime && {isAnime &&
serverData.server.activeAnimeDirectory === folder.path serverData.server.activeAnimeDirectory === folder.path
@@ -299,6 +320,131 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</select> </select>
</div> </div>
</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 && ( {isAnime && (
<div className="mt-4 italic"> <div className="mt-4 italic">
{intl.formatMessage(messages.animenote)} {intl.formatMessage(messages.animenote)}

View File

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

View File

@@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { MediaRequest } from '../../../server/entity/MediaRequest'; import { MediaRequest } from '../../../server/entity/MediaRequest';
import useSWR from 'swr'; import useSWR from 'swr';
import { useToasts } from 'react-toast-notifications'; 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 axios from 'axios';
import { import {
MediaStatus, MediaStatus,
@@ -103,6 +103,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
serverId: requestOverrides?.server, serverId: requestOverrides?.server,
profileId: requestOverrides?.profile, profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder, rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
seasons: selectedSeasons, seasons: selectedSeasons,
}); });
} else { } else {
@@ -150,6 +151,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
serverId: requestOverrides.server, serverId: requestOverrides.server,
profileId: requestOverrides.profile, profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder, rootFolder: requestOverrides.folder,
userId: requestOverrides?.user?.id,
}; };
} }
const response = await axios.post<MediaRequest>('/api/v1/request', { const response = await axios.post<MediaRequest>('/api/v1/request', {
@@ -391,7 +393,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleAllSeasons(); 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 <span
aria-hidden="true" aria-hidden="true"
@@ -451,7 +453,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleSeason(season.seasonNumber); 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 || mediaSeason ||
(!!seasonRequest && (!!seasonRequest &&
!editingSeasons.includes(season.seasonNumber)) !editingSeasons.includes(season.seasonNumber))
@@ -550,7 +552,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
</div> </div>
</div> </div>
</div> </div>
{hasPermission(Permission.REQUEST_ADVANCED) && ( {(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<div className="mt-4"> <div className="mt-4">
<AdvancedRequester <AdvancedRequester
type="tv" type="tv"
@@ -559,6 +562,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
(keyword) => keyword.id === ANIME_KEYWORD_ID (keyword) => keyword.id === ANIME_KEYWORD_ID
)} )}
onChange={(overrides) => setRequestOverrides(overrides)} onChange={(overrides) => setRequestOverrides(overrides)}
requestUser={editRequest?.requestedBy}
defaultOverrides={ defaultOverrides={
editRequest editRequest
? { ? {

View File

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

View File

@@ -29,7 +29,7 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => {
e.preventDefault(); e.preventDefault();
setCopied(); 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 <svg
className="w-5 h-5 text-white" className="w-5 h-5 text-white"

View File

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

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