Merge branch 'develop'

master
sct 1 year ago
commit 2dc0009675

@ -773,6 +773,105 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "lunks",
"name": "Pedro Nascimento",
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
"profile": "http://twitter.com/lunks/",
"contributions": [
"code"
]
},
{
"login": "owenvoke",
"name": "Owen Voke",
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
"profile": "https://voke.dev",
"contributions": [
"code"
]
},
{
"login": "Nimelrian",
"name": "Sebastian K",
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
"profile": "https://github.com/Nimelrian",
"contributions": [
"code"
]
},
{
"login": "jariz",
"name": "jariz",
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
"profile": "https://github.com/jariz",
"contributions": [
"code"
]
},
{
"login": "Alexays",
"name": "Alex",
"avatar_url": "https://avatars.githubusercontent.com/u/13947260?v=4",
"profile": "https://arouillard.fr",
"contributions": [
"code"
]
},
{
"login": "Zebebles",
"name": "Zeb Muller",
"avatar_url": "https://avatars.githubusercontent.com/u/11425451?v=4",
"profile": "https://github.com/Zebebles",
"contributions": [
"code"
]
},
{
"login": "SMores",
"name": "Shane Friedman",
"avatar_url": "https://avatars.githubusercontent.com/u/5354254?v=4",
"profile": "http://smoores.dev",
"contributions": [
"code"
]
},
{
"login": "IzaacJ",
"name": "Izaac Brånn",
"avatar_url": "https://avatars.githubusercontent.com/u/711323?v=4",
"profile": "https://izaacj.me",
"contributions": [
"code"
]
},
{
"login": "SalmanTariq",
"name": "Salman Tariq",
"avatar_url": "https://avatars.githubusercontent.com/u/13284494?v=4",
"profile": "https://github.com/SalmanTariq",
"contributions": [
"code"
]
},
{
"login": "andrew-kennedy",
"name": "Andrew Kennedy",
"avatar_url": "https://avatars.githubusercontent.com/u/2387159?v=4",
"profile": "https://github.com/andrew-kennedy",
"contributions": [
"code"
]
},
{
"login": "Fallenbagel",
"name": "Fallenbagel",
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?v=4",
"profile": "https://github.com/Fallenbagel",
"contributions": [
"code"
]
} }
], ],
"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>",
@ -782,5 +881,6 @@
"repoType": "github", "repoType": "github",
"repoHost": "https://github.com", "repoHost": "https://github.com",
"skipCi": false, "skipCi": false,
"commitConvention": "angular" "commitConvention": "angular",
"commitType": "docs"
} }

10
.github/CODEOWNERS vendored

@ -1,10 +1,10 @@
# Global code ownership # Global code ownership
* @sct @TheCatLady @danshilm * @sct @TheCatLady @danshilm @OwsleyJr
# Documentation # Documentation
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm /.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/*.md @TheCatLady @samwiseg0 @danshilm /*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/docs/ @TheCatLady @samwiseg0 @danshilm /docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr
# Snap-related files # Snap-related files
/.github/workflows/snap.yaml @samwiseg0 /.github/workflows/snap.yaml @samwiseg0
@ -12,4 +12,4 @@
# i18n locale files # i18n locale files
/src/i18n/locale/ @sct @TheCatLady /src/i18n/locale/ @sct @TheCatLady
/src/i18n/locale/en.json @sct @TheCatLady @danshilm /src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr

@ -16,5 +16,8 @@
} }
], ],
"editor.formatOnSave": true, "editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "non-relative" "typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"globals.css": "tailwindcss"
}
} }

@ -11,7 +11,7 @@
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a> <a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a> <a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- 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-84-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-95-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
</p> </p>
@ -182,6 +182,21 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
</tr> </tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
</tr>
</tbody> </tbody>
</table> </table>

@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
url: '/api/v1/*', url: '/api/v1/*',
}).as('apiCall'); }).as('apiCall');
cy.get('.searchbar').swipe('bottom', [190, 400]); cy.get('.searchbar').swipe('bottom', [190, 500]);
cy.wait('@apiCall').then((interception) => { cy.wait('@apiCall').then((interception) => {
assert.isNotNull( assert.isNotNull(

@ -96,7 +96,7 @@ describe('Discover Customization', () => {
.should('be.disabled'); .should('be.disabled');
cy.get('#data').clear(); cy.get('#data').clear();
cy.get('#data').type('time travel{enter}', { delay: 100 }); cy.get('#data').type('christmas{enter}', { delay: 100 });
// Confirming we have some results // Confirming we have some results
cy.contains('.slider-header', sliderTitle) cy.contains('.slider-header', sliderTitle)

@ -19,5 +19,6 @@ module.exports = {
}, },
experimental: { experimental: {
scrollRestoration: true, scrollRestoration: true,
largePageDataBytes: 256000,
}, },
}; };

@ -3615,7 +3615,7 @@ paths:
$ref: '#/components/schemas/User' $ref: '#/components/schemas/User'
/user/{userId}/requests: /user/{userId}/requests:
get: get:
summary: Get user by ID summary: Get requests for a specific user
description: | description: |
Retrieves a user's requests in a JSON object. Retrieves a user's requests in a JSON object.
tags: tags:
@ -3711,7 +3711,7 @@ paths:
example: false example: false
/user/{userId}/watchlist: /user/{userId}/watchlist:
get: get:
summary: Get user by ID summary: Get the Plex watchlist for a specific user
description: | description: |
Retrieves a user's Plex Watchlist in a JSON object. Retrieves a user's Plex Watchlist in a JSON object.
tags: tags:
@ -4186,6 +4186,16 @@ paths:
schema: schema:
type: number type: number
example: 10 example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query - in: query
name: watchRegion name: watchRegion
schema: schema:
@ -4465,6 +4475,16 @@ paths:
schema: schema:
type: number type: number
example: 10 example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query - in: query
name: watchRegion name: watchRegion
schema: schema:

@ -29,17 +29,17 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@formatjs/intl-displaynames": "6.2.3", "@formatjs/intl-displaynames": "6.2.6",
"@formatjs/intl-locale": "3.0.11", "@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.8", "@formatjs/intl-pluralrules": "5.1.10",
"@formatjs/intl-utils": "3.8.4", "@formatjs/intl-utils": "3.8.4",
"@headlessui/react": "1.7.7", "@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.13", "@heroicons/react": "2.0.16",
"@supercharge/request-ip": "1.2.0", "@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1", "@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.22", "@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.14.0", "ace-builds": "1.15.2",
"axios": "1.2.2", "axios": "1.3.4",
"axios-rate-limit": "1.3.0", "axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
@ -47,7 +47,7 @@
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5", "country-flag-icons": "1.5.5",
"cronstrue": "2.21.0", "cronstrue": "2.23.0",
"csurf": "1.11.0", "csurf": "1.11.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
"dayjs": "1.11.7", "dayjs": "1.11.7",
@ -63,23 +63,22 @@
"next": "12.3.4", "next": "12.3.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "9.3.1",
"node-schedule": "2.1.0", "node-schedule": "2.1.1",
"nodemailer": "6.8.0", "nodemailer": "6.9.1",
"openpgp": "5.5.0", "openpgp": "5.7.0",
"plex-api": "5.3.2", "plex-api": "5.3.2",
"pug": "3.0.2", "pug": "3.0.2",
"pulltorefreshjs": "0.1.22",
"react": "18.2.0", "react": "18.2.0",
"react-ace": "10.1.0", "react-ace": "10.1.0",
"react-animate-height": "2.1.2", "react-animate-height": "2.1.2",
"react-aria": "3.22.0", "react-aria": "3.23.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-intersection-observer": "9.4.1", "react-intersection-observer": "9.4.3",
"react-intl": "6.2.5", "react-intl": "6.2.10",
"react-markdown": "8.0.4", "react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2", "react-popper-tooltip": "4.4.2",
"react-select": "5.7.0", "react-select": "5.7.0",
"react-spring": "9.6.1", "react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4", "react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1", "react-toast-notifications": "2.5.1",
"react-truncate-markup": "5.1.2", "react-truncate-markup": "5.1.2",
@ -88,42 +87,41 @@
"secure-random-password": "0.2.3", "secure-random-password": "0.2.3",
"semver": "7.3.8", "semver": "7.3.8",
"sqlite3": "5.1.4", "sqlite3": "5.1.4",
"swagger-ui-express": "4.6.0", "swagger-ui-express": "4.6.2",
"swr": "2.0.0", "swr": "2.0.4",
"typeorm": "0.3.11", "typeorm": "0.3.12",
"web-push": "3.5.0", "web-push": "3.5.0",
"winston": "3.8.2", "winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1", "winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23", "xml2js": "0.4.23",
"yamljs": "0.3.0", "yamljs": "0.3.0",
"yup": "0.32.11", "yup": "0.32.11",
"zod": "3.20.2" "zod": "3.20.6"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.20.7", "@babel/cli": "7.21.0",
"@commitlint/cli": "17.4.0", "@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.0", "@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2", "@semantic-release/changelog": "6.0.2",
"@semantic-release/commit-analyzer": "9.0.2", "@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/exec": "6.0.3", "@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1", "@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.2", "@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.3", "@tailwindcss/forms": "0.5.3",
"@tailwindcss/typography": "0.5.8", "@tailwindcss/typography": "0.5.9",
"@types/bcrypt": "5.0.0", "@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3", "@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0", "@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2", "@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4", "@types/email-templates": "8.0.4",
"@types/express": "4.17.15", "@types/express": "4.17.17",
"@types/express-session": "1.17.5", "@types/express-session": "1.17.6",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/node": "17.0.36", "@types/node": "17.0.36",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/pulltorefreshjs": "0.1.5", "@types/react": "18.0.28",
"@types/react": "18.0.26", "@types/react-dom": "18.0.11",
"@types/react-dom": "18.0.10",
"@types/react-transition-group": "4.4.5", "@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1", "@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13", "@types/semver": "7.3.13",
@ -132,45 +130,46 @@
"@types/xml2js": "0.4.11", "@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31", "@types/yamljs": "0.2.31",
"@types/yup": "0.29.14", "@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.48.0", "@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.48.0", "@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13", "autoprefixer": "10.4.13",
"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.6", "commitizen": "4.3.0",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0", "cy-mobile-commands": "0.3.0",
"cypress": "12.3.0", "cypress": "12.7.0",
"cz-conventional-changelog": "3.3.0", "cz-conventional-changelog": "3.3.0",
"eslint": "8.31.0", "eslint": "8.35.0",
"eslint-config-next": "12.3.4", "eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0", "eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.3.9", "eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.6.1", "eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-no-relative-import-paths": "1.5.2", "eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.31.11", "eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1", "extract-react-intl-messages": "4.1.1",
"husky": "8.0.3", "husky": "8.0.3",
"lint-staged": "13.1.0", "lint-staged": "13.1.2",
"nodemon": "2.0.20", "nodemon": "2.0.20",
"postcss": "8.4.20", "postcss": "8.4.21",
"prettier": "2.8.1", "prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.1", "prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.1", "prettier-plugin-tailwindcss": "0.2.3",
"semantic-release": "19.0.5", "semantic-release": "19.0.5",
"semantic-release-docker-buildx": "1.0.1", "semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.2.4", "tailwindcss": "3.2.7",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"tsc-alias": "1.8.2", "tsc-alias": "1.8.2",
"tsconfig-paths": "4.1.2", "tsconfig-paths": "4.1.2",
"typescript": "4.9.4" "typescript": "4.9.5"
}, },
"resolutions": { "resolutions": {
"sqlite3/node-gyp": "8.4.1", "sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.26", "@types/react": "18.0.28",
"@types/react-dom": "18.0.10" "@types/react-dom": "18.0.11",
"@types/express-session": "1.17.6"
}, },
"config": { "config": {
"commitizen": { "commitizen": {

@ -17,7 +17,7 @@ interface RTAlgoliaHit {
title: string; title: string;
titles: string[]; titles: string[];
description: string; description: string;
releaseYear: string; releaseYear: number;
rating: string; rating: string;
genres: string[]; genres: string[];
updateDate: string; updateDate: string;
@ -111,22 +111,19 @@ class RottenTomatoes extends ExternalAPI {
// First, attempt to match exact name and year // First, attempt to match exact name and year
let movie = contentResults.hits.find( let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString() && movie.title === name (movie) => movie.releaseYear === 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 = contentResults.hits.find( movie = contentResults.hits.find(
(movie) => (movie) => movie.releaseYear === year && movie.title.includes(name)
movie.releaseYear === year.toString() && 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 = contentResults.hits.find( movie = contentResults.hits.find((movie) => movie.releaseYear === year);
(movie) => movie.releaseYear === year.toString()
);
} }
// One last try, try exact name match only // One last try, try exact name match only
@ -181,7 +178,7 @@ class RottenTomatoes extends ExternalAPI {
if (year) { if (year) {
tvshow = contentResults.hits.find( tvshow = contentResults.hits.find(
(series) => series.releaseYear === year.toString() (series) => series.releaseYear === year
); );
} }

@ -1,7 +1,7 @@
import logger from '@server/logger'; import logger from '@server/logger';
import ServarrBase from './base'; import ServarrBase from './base';
interface SonarrSeason { export interface SonarrSeason {
seasonNumber: number; seasonNumber: number;
monitored: boolean; monitored: boolean;
statistics?: { statistics?: {
@ -76,6 +76,15 @@ export interface SonarrSeries {
ignoreEpisodesWithoutFiles?: boolean; ignoreEpisodesWithoutFiles?: boolean;
searchForMissingEpisodes?: boolean; searchForMissingEpisodes?: boolean;
}; };
statistics: {
seasonCount: number;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
releaseGroups: string[];
percentOfEpisodes: number;
};
} }
export interface AddSeriesOptions { export interface AddSeriesOptions {
@ -116,6 +125,16 @@ class SonarrAPI extends ServarrBase<{
} }
} }
public async getSeriesById(id: number): Promise<SonarrSeries> {
try {
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
}
}
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> { public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try { try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', { const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {

@ -65,6 +65,8 @@ interface DiscoverMovieOptions {
withRuntimeLte?: string; withRuntimeLte?: string;
voteAverageGte?: string; voteAverageGte?: string;
voteAverageLte?: string; voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
originalLanguage?: string; originalLanguage?: string;
genre?: string; genre?: string;
studio?: string; studio?: string;
@ -83,6 +85,8 @@ interface DiscoverTvOptions {
withRuntimeLte?: string; withRuntimeLte?: string;
voteAverageGte?: string; voteAverageGte?: string;
voteAverageLte?: string; voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
includeEmptyReleaseDate?: boolean; includeEmptyReleaseDate?: boolean;
originalLanguage?: string; originalLanguage?: string;
genre?: string; genre?: string;
@ -460,6 +464,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte, withRuntimeLte,
voteAverageGte, voteAverageGte,
voteAverageLte, voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders, watchProviders,
watchRegion, watchRegion,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => { }: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
@ -504,6 +510,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte, 'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte, 'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte, 'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
watch_region: watchRegion, watch_region: watchRegion,
with_watch_providers: watchProviders, with_watch_providers: watchProviders,
}, },
@ -530,6 +538,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte, withRuntimeLte,
voteAverageGte, voteAverageGte,
voteAverageLte, voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders, watchProviders,
watchRegion, watchRegion,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => { }: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
@ -574,6 +584,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte, 'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte, 'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte, 'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders, with_watch_providers: watchProviders,
watch_region: watchRegion, watch_region: watchRegion,
}, },

@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
first_air_date: string; first_air_date: string;
} }
export interface TmdbCollectionResult {
id: number;
media_type: 'collection';
title: string;
original_title: string;
adult: boolean;
poster_path?: string;
backdrop_path?: string;
overview: string;
original_language: string;
}
export interface TmdbPersonResult { export interface TmdbPersonResult {
id: number; id: number;
name: string; name: string;
@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
} }
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
} }
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {

@ -20,6 +20,8 @@ export enum DiscoverSliderType {
TMDB_SEARCH, TMDB_SEARCH,
TMDB_STUDIO, TMDB_STUDIO,
TMDB_NETWORK, TMDB_NETWORK,
TMDB_MOVIE_STREAMING_SERVICES,
TMDB_TV_STREAMING_SERVICES,
} }
export const defaultSliders: Partial<DiscoverSlider>[] = [ export const defaultSliders: Partial<DiscoverSlider>[] = [

@ -114,29 +114,29 @@ class Media {
@Column({ type: 'datetime', nullable: true }) @Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date; public mediaAddedAt: Date;
@Column({ nullable: true }) @Column({ nullable: true, type: 'int' })
public serviceId?: number; public serviceId?: number | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'int' })
public serviceId4k?: number; public serviceId4k?: number | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'int' })
public externalServiceId?: number; public externalServiceId?: number | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'int' })
public externalServiceId4k?: number; public externalServiceId4k?: number | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'varchar' })
public externalServiceSlug?: string; public externalServiceSlug?: string | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'varchar' })
public externalServiceSlug4k?: string; public externalServiceSlug4k?: string | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'varchar' })
public ratingKey?: string; public ratingKey?: string | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string; public ratingKey4k?: string | null;
public serviceUrl?: string; public serviceUrl?: string;
public serviceUrl4k?: string; public serviceUrl4k?: string;
@ -260,7 +260,9 @@ class Media {
if (this.mediaType === MediaType.MOVIE) { if (this.mediaType === MediaType.MOVIE) {
if ( if (
this.externalServiceId !== undefined && this.externalServiceId !== undefined &&
this.serviceId !== undefined this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) { ) {
this.downloadStatus = downloadTracker.getMovieProgress( this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId, this.serviceId,
@ -270,7 +272,9 @@ class Media {
if ( if (
this.externalServiceId4k !== undefined && this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) { ) {
this.downloadStatus4k = downloadTracker.getMovieProgress( this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k, this.serviceId4k,
@ -282,7 +286,9 @@ class Media {
if (this.mediaType === MediaType.TV) { if (this.mediaType === MediaType.TV) {
if ( if (
this.externalServiceId !== undefined && this.externalServiceId !== undefined &&
this.serviceId !== undefined this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) { ) {
this.downloadStatus = downloadTracker.getSeriesProgress( this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId, this.serviceId,
@ -292,7 +298,9 @@ class Media {
if ( if (
this.externalServiceId4k !== undefined && this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) { ) {
this.downloadStatus4k = downloadTracker.getSeriesProgress( this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k, this.serviceId4k,

@ -704,7 +704,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory; let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId; let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags; let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
if ( if (
this.rootFolder && this.rootFolder &&
@ -764,6 +764,38 @@ export class MediaRequest {
return; return;
} }
if (radarrSettings.tagRequests) {
let userTag = (await radarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await radarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
});
}
}
if ( if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) { ) {
@ -970,7 +1002,11 @@ export class MediaRequest {
let tags = let tags =
seriesType === 'anime' seriesType === 'anime'
? sonarrSettings.animeTags ? sonarrSettings.animeTags
: sonarrSettings.tags; ? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
if ( if (
this.rootFolder && this.rootFolder &&
@ -1022,6 +1058,38 @@ export class MediaRequest {
}); });
} }
if (sonarrSettings.tagRequests) {
let userTag = (await sonarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await sonarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
});
}
}
const sonarrSeriesOptions: AddSeriesOptions = { const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile, profileId: qualityProfile,
languageProfileId: languageProfile, languageProfileId: languageProfile,
@ -1187,3 +1255,5 @@ export class MediaRequest {
} }
} }
} }
export default MediaRequest;

@ -1,5 +1,7 @@
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { import {
AfterRemove,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -34,6 +36,18 @@ class SeasonRequest {
constructor(init?: Partial<SeasonRequest>) { constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init); Object.assign(this, init);
} }
@AfterRemove()
public async handleRemoveParent(): Promise<void> {
const mediaRequestRepository = getRepository(MediaRequest);
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
where: { id: this.request.id },
});
if (requestToBeDeleted.seasons.length === 0) {
await mediaRequestRepository.delete({ id: this.request.id });
}
}
} }
export default SeasonRequest; export default SeasonRequest;

@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush'; import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
import imageproxy from '@server/routes/imageproxy'; import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
@ -182,7 +183,8 @@ app
}); });
server.use('/api/v1', routes); server.use('/api/v1', routes);
server.use('/imageproxy', imageproxy); // Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.get('*', (req, res) => handle(req, res)); server.get('*', (req, res) => handle(req, res));
server.use( server.use(

@ -1,3 +1,4 @@
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy'; import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
@ -7,6 +8,7 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync'; import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger'; import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule'; import schedule from 'node-schedule';
interface ScheduledJob { interface ScheduledJob {
@ -14,7 +16,7 @@ interface ScheduledJob {
job: schedule.Job; job: schedule.Job;
name: string; name: string;
type: 'process' | 'command'; type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed'; interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string; cronSchedule: string;
running?: () => boolean; running?: () => boolean;
cancelFn?: () => void; cancelFn?: () => void;
@ -30,7 +32,7 @@ export const startJobs = (): void => {
id: 'plex-recently-added-scan', id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan', name: 'Plex Recently Added Scan',
type: 'process', type: 'process',
interval: 'short', interval: 'minutes',
cronSchedule: jobs['plex-recently-added-scan'].schedule, cronSchedule: jobs['plex-recently-added-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => { job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Recently Added Scan', { logger.info('Starting scheduled job: Plex Recently Added Scan', {
@ -47,7 +49,7 @@ export const startJobs = (): void => {
id: 'plex-full-scan', id: 'plex-full-scan',
name: 'Plex Full Library Scan', name: 'Plex Full Library Scan',
type: 'process', type: 'process',
interval: 'long', interval: 'hours',
cronSchedule: jobs['plex-full-scan'].schedule, cronSchedule: jobs['plex-full-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => { job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', { logger.info('Starting scheduled job: Plex Full Library Scan', {
@ -59,27 +61,37 @@ export const startJobs = (): void => {
cancelFn: () => plexFullScanner.cancel(), cancelFn: () => plexFullScanner.cancel(),
}); });
// Run watchlist sync every 5 minutes // Watchlist Sync
scheduledJobs.push({ const watchlistSyncJob: ScheduledJob = {
id: 'plex-watchlist-sync', id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync', name: 'Plex Watchlist Sync',
type: 'process', type: 'process',
interval: 'short', interval: 'fixed',
cronSchedule: jobs['plex-watchlist-sync'].schedule, cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', { logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs', label: 'Jobs',
}); });
watchlistSync.syncWatchlist(); watchlistSync.syncWatchlist();
}), }),
};
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
// after each run
watchlistSyncJob.job.on('run', () => {
watchlistSyncJob.job.schedule(
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
);
}); });
scheduledJobs.push(watchlistSyncJob);
// Run full radarr scan every 24 hours // Run full radarr scan every 24 hours
scheduledJobs.push({ scheduledJobs.push({
id: 'radarr-scan', id: 'radarr-scan',
name: 'Radarr Scan', name: 'Radarr Scan',
type: 'process', type: 'process',
interval: 'long', interval: 'hours',
cronSchedule: jobs['radarr-scan'].schedule, cronSchedule: jobs['radarr-scan'].schedule,
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
@ -94,7 +106,7 @@ export const startJobs = (): void => {
id: 'sonarr-scan', id: 'sonarr-scan',
name: 'Sonarr Scan', name: 'Sonarr Scan',
type: 'process', type: 'process',
interval: 'long', interval: 'hours',
cronSchedule: jobs['sonarr-scan'].schedule, cronSchedule: jobs['sonarr-scan'].schedule,
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
@ -104,12 +116,29 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(), cancelFn: () => sonarrScanner.cancel(),
}); });
// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
type: 'process',
interval: 'hours',
cronSchedule: jobs['availability-sync'].schedule,
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
logger.info('Starting scheduled job: Media Availability Sync', {
label: 'Jobs',
});
availabilitySync.run();
}),
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});
// Run download sync every minute // Run download sync every minute
scheduledJobs.push({ scheduledJobs.push({
id: 'download-sync', id: 'download-sync',
name: 'Download Sync', name: 'Download Sync',
type: 'command', type: 'command',
interval: 'fixed', interval: 'seconds',
cronSchedule: jobs['download-sync'].schedule, cronSchedule: jobs['download-sync'].schedule,
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => { job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', { logger.debug('Starting scheduled job: Download Sync', {
@ -124,7 +153,7 @@ export const startJobs = (): void => {
id: 'download-sync-reset', id: 'download-sync-reset',
name: 'Download Sync Reset', name: 'Download Sync Reset',
type: 'command', type: 'command',
interval: 'long', interval: 'hours',
cronSchedule: jobs['download-sync-reset'].schedule, cronSchedule: jobs['download-sync-reset'].schedule,
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => { job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', { logger.info('Starting scheduled job: Download Sync Reset', {
@ -134,12 +163,12 @@ export const startJobs = (): void => {
}), }),
}); });
// Run image cache cleanup every 5 minutes // Run image cache cleanup every 24 hours
scheduledJobs.push({ scheduledJobs.push({
id: 'image-cache-cleanup', id: 'image-cache-cleanup',
name: 'Image Cache Cleanup', name: 'Image Cache Cleanup',
type: 'process', type: 'process',
interval: 'long', interval: 'hours',
cronSchedule: jobs['image-cache-cleanup'].schedule, cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => { job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', { logger.info('Starting scheduled job: Image Cache Cleanup', {

@ -0,0 +1,817 @@
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import type { RadarrMovie } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
class AvailabilitySync {
public running = false;
private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];
async run() {
const settings = getSettings();
this.running = true;
this.plexSeasonsCache = {};
this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
try {
await this.initPlexClient();
if (!this.plexClient) {
return;
}
logger.info(`Starting availability sync...`, {
label: 'AvailabilitySync',
});
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
const pageSize = 50;
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
if (!this.running) {
throw new Error('Job aborted');
}
const mediaExists = await this.mediaExists(media);
// We can not delete media so if both versions do not exist, we will change both columns to unknown or null
if (!mediaExists) {
if (
media.status !== MediaStatus.UNKNOWN ||
media.status4k !== MediaStatus.UNKNOWN
) {
const request = await requestRepository.find({
relations: {
media: true,
},
where: { media: { id: media.id } },
});
logger.info(
`Media ID ${media.id} does not exist in any of your media instances. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
});
await requestRepository.remove(request);
}
}
if (media.mediaType === 'tv') {
// ok, the show itself exists, but do all it's seasons?
const seasons = await seasonRepository.find({
where: [
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status4k: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
],
});
let didDeleteSeasons = false;
for (const season of seasons) {
if (
!mediaExists &&
(season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN)
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
} else {
const seasonExists = await this.seasonExists(media, season);
if (!seasonExists) {
logger.info(
`Removing season ${season.seasonNumber}, media ID ${media.id} because it does not exist in any of your media instances.`,
{ label: 'AvailabilitySync' }
);
if (
season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
}
const seasonToBeDeleted = await seasonRequestRepository.findOne(
{
relations: {
request: {
media: true,
},
},
where: {
request: {
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
}
);
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
didDeleteSeasons = true;
}
}
if (didDeleteSeasons) {
if (
media.status === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.AVAILABLE
) {
logger.info(
`Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
if (media.status === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
if (media.status4k === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
}
}
}
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} finally {
logger.info(`Availability sync complete.`, {
label: 'AvailabilitySync',
});
this.running = false;
}
}
public cancel() {
this.running = false;
}
private async *loadAvailableMediaPaginated(pageSize: number) {
let offset = 0;
const mediaRepository = getRepository(Media);
const whereOptions = [
{ status: MediaStatus.AVAILABLE },
{ status: MediaStatus.PARTIALLY_AVAILABLE },
{ status4k: MediaStatus.AVAILABLE },
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
];
let mediaPage: Media[];
do {
yield* (mediaPage = await mediaRepository.find({
where: whereOptions,
skip: offset,
take: pageSize,
}));
offset += pageSize;
} while (mediaPage.length > 0);
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const isTVType = media.mediaType === 'tv';
try {
const request = await requestRepository.findOne({
relations: {
media: true,
},
where: { media: { id: media.id }, is4k: is4k ? true : false },
});
logger.info(
`Media ID ${media.id} does not exist in your ${
is4k ? '4k' : 'non-4k'
} ${
isTVType ? 'Sonarr' : 'Radarr'
} and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(
media.id,
is4k
? {
status4k: MediaStatus.UNKNOWN,
serviceId4k: null,
externalServiceId4k: null,
externalServiceSlug4k: null,
ratingKey4k: null,
}
: {
status: MediaStatus.UNKNOWN,
serviceId: null,
externalServiceId: null,
externalServiceSlug: null,
ratingKey: null,
}
);
if (isTVType) {
const seasonRepository = getRepository(Season);
await seasonRepository?.update(
{ media: { id: media.id } },
is4k
? { status4k: MediaStatus.UNKNOWN }
: { status: MediaStatus.UNKNOWN }
);
}
await requestRepository.delete({ id: request?.id });
} catch (ex) {
logger.debug(`Failure updating media ID ${media.id}`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
private async mediaExistsInRadarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
let existsInRadarr = true;
let existsInRadarr4k = true;
for (const server of this.radarrServers) {
const api = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
try {
// Check if both exist or if a single non-4k or 4k exists
// If both do not exist we will return false
let meta: RadarrMovie | undefined;
if (!server.is4k && media.externalServiceId) {
meta = await api.getMovie({ id: media.externalServiceId });
}
if (server.is4k && media.externalServiceId4k) {
meta = await api.getMovie({ id: media.externalServiceId4k });
}
if (!server.is4k && (!meta || !meta.hasFile)) {
existsInRadarr = false;
}
if (server.is4k && (!meta || !meta.hasFile)) {
existsInRadarr4k = false;
}
} catch (ex) {
logger.debug(
`Failure retrieving media ID ${media.id} from your ${
!server.is4k ? 'non-4K' : '4K'
} Radarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
if (!server.is4k) {
existsInRadarr = false;
}
if (server.is4k) {
existsInRadarr4k = false;
}
}
}
// If only a single non-4k or 4k exists, then change entity columns accordingly
// Related media request will then be deleted
if (
!existsInRadarr &&
(existsInRadarr4k || existsInPlex4k) &&
!existsInPlex
) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (
(existsInRadarr || existsInPlex) &&
!existsInRadarr4k &&
!existsInPlex4k
) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInRadarr || existsInRadarr4k || existsInPlex || existsInPlex4k) {
return true;
}
return false;
}
private async mediaExistsInSonarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
let existsInSonarr = true;
let existsInSonarr4k = true;
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
try {
// Check if both exist or if a single non-4k or 4k exists
// If both do not exist we will return false
let meta: SonarrSeries | undefined;
if (!server.is4k && media.externalServiceId) {
meta = await api.getSeriesById(media.externalServiceId);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
meta.seasons;
}
if (server.is4k && media.externalServiceId4k) {
meta = await api.getSeriesById(media.externalServiceId4k);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
meta.seasons;
}
if (!server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) {
existsInSonarr = false;
}
if (server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) {
existsInSonarr4k = false;
}
} catch (ex) {
logger.debug(
`Failure retrieving media ID ${media.id} from your ${
!server.is4k ? 'non-4K' : '4K'
} Sonarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
if (!server.is4k) {
existsInSonarr = false;
}
if (server.is4k) {
existsInSonarr4k = false;
}
}
}
// If only a single non-4k or 4k exists, then change entity columns accordingly
// Related media request will then be deleted
if (
!existsInSonarr &&
(existsInSonarr4k || existsInPlex4k) &&
!existsInPlex
) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (
(existsInSonarr || existsInPlex) &&
!existsInSonarr4k &&
!existsInPlex4k
) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInSonarr || existsInSonarr4k || existsInPlex || existsInPlex4k) {
return true;
}
return false;
}
private async seasonExistsInSonarr(
media: Media,
season: Season,
seasonExistsInPlex: boolean,
seasonExistsInPlex4k: boolean
): Promise<boolean> {
let seasonExistsInSonarr = true;
let seasonExistsInSonarr4k = true;
const mediaRepository = getRepository(Media);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
try {
// Here we can use the cache we built when we fetched the series with mediaExistsInSonarr
// If the cache does not have data, we will fetch with the api route
let seasons: SonarrSeason[] =
this.sonarrSeasonsCache[
`${server.id}-${
!server.is4k ? media.externalServiceId : media.externalServiceId4k
}`
];
if (!server.is4k && media.externalServiceId) {
seasons =
this.sonarrSeasonsCache[
`${server.id}-${media.externalServiceId}`
] ?? (await api.getSeriesById(media.externalServiceId)).seasons;
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
seasons;
}
if (server.is4k && media.externalServiceId4k) {
seasons =
this.sonarrSeasonsCache[
`${server.id}-${media.externalServiceId4k}`
] ?? (await api.getSeriesById(media.externalServiceId4k)).seasons;
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
seasons;
}
const seasonIsUnavailable = seasons?.find(
({ seasonNumber, statistics }) =>
season.seasonNumber === seasonNumber &&
statistics?.episodeFileCount === 0
);
if (!server.is4k && seasonIsUnavailable) {
seasonExistsInSonarr = false;
}
if (server.is4k && seasonIsUnavailable) {
seasonExistsInSonarr4k = false;
}
} catch (ex) {
logger.debug(
`Failure retrieving media ID ${media.id} from your ${
!server.is4k ? 'non-4K' : '4K'
} Sonarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
if (!server.is4k) {
seasonExistsInSonarr = false;
}
if (server.is4k) {
seasonExistsInSonarr4k = false;
}
}
}
try {
const seasonToBeDeleted = await seasonRequestRepository.findOne({
relations: {
request: {
media: true,
},
},
where: {
request: {
is4k: seasonExistsInSonarr ? true : false,
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
});
// If season does not exist, we will change status to unknown and delete related season request
// If parent media request is empty(all related seasons have been removed), parent is automatically deleted
if (
!seasonExistsInSonarr &&
(seasonExistsInSonarr4k || seasonExistsInPlex4k) &&
!seasonExistsInPlex
) {
if (season.status !== MediaStatus.UNKNOWN) {
logger.info(
`Season ${season.seasonNumber}, media ID ${media.id} does not exist in your non-4k Sonarr and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status === MediaStatus.AVAILABLE) {
logger.info(
`Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
if (
(seasonExistsInSonarr || seasonExistsInPlex) &&
!seasonExistsInSonarr4k &&
!seasonExistsInPlex4k
) {
if (season.status4k !== MediaStatus.UNKNOWN) {
logger.info(
`Season ${season.seasonNumber}, media ID ${media.id} does not exist in your 4k Sonarr and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status4k: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status4k === MediaStatus.AVAILABLE) {
logger.info(
`Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
} catch (ex) {
logger.debug(`Failure updating media ID ${media.id}`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
if (
seasonExistsInSonarr ||
seasonExistsInSonarr4k ||
seasonExistsInPlex ||
seasonExistsInPlex4k
) {
return true;
}
return false;
}
private async mediaExists(media: Media): Promise<boolean> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let existsInPlex = false;
let existsInPlex4k = false;
// Check each plex instance to see if media exists
try {
if (ratingKey) {
const meta = await this.plexClient?.getMetadata(ratingKey);
if (meta) {
existsInPlex = true;
}
}
if (ratingKey4k) {
const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
if (meta4k) {
existsInPlex4k = true;
}
}
} catch (ex) {
if (!ex.message.includes('response code: 404')) {
logger.debug(`Failed to retrieve plex metadata`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
// Base case if both media versions exist in plex
if (existsInPlex && existsInPlex4k) {
return true;
}
// We then check radarr or sonarr has that specific media. If not, then we will move to delete
// If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
if (media.mediaType === 'movie') {
const existsInRadarr = await this.mediaExistsInRadarr(
media,
existsInPlex,
existsInPlex4k
);
// If true, media exists in at least one radarr or plex instance.
if (existsInRadarr) {
logger.warn(
`${media.id} exists in at least one Radarr or Plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
if (media.mediaType === 'tv') {
const existsInSonarr = await this.mediaExistsInSonarr(
media,
existsInPlex,
existsInPlex4k
);
// If true, media exists in at least one sonarr or plex instance.
if (existsInSonarr) {
logger.warn(
`${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
return false;
}
private async seasonExists(media: Media, season: Season) {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let seasonExistsInPlex = false;
let seasonExistsInPlex4k = false;
try {
if (ratingKey) {
const children =
this.plexSeasonsCache[ratingKey] ??
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
[];
this.plexSeasonsCache[ratingKey] = children;
const seasonMeta = children?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta) {
seasonExistsInPlex = true;
}
}
if (ratingKey4k) {
const children4k =
this.plexSeasonsCache[ratingKey4k] ??
(await this.plexClient?.getChildrenMetadata(ratingKey4k)) ??
[];
this.plexSeasonsCache[ratingKey4k] = children4k;
const seasonMeta4k = children4k?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta4k) {
seasonExistsInPlex4k = true;
}
}
} catch (ex) {
if (!ex.message.includes('response code: 404')) {
logger.debug(`Failed to retrieve plex's children metadata`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
// Base case if both season versions exist in plex
if (seasonExistsInPlex && seasonExistsInPlex4k) {
return true;
}
const existsInSonarr = await this.seasonExistsInSonarr(
media,
season,
seasonExistsInPlex,
seasonExistsInPlex4k
);
if (existsInSonarr) {
logger.warn(
`Season ${season.seasonNumber}, media ID ${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
return false;
}
private async initPlexClient() {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (!admin) {
logger.warning('No admin configured. Availability sync skipped.');
return;
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
}
}
const availabilitySync = new AvailabilitySync();
export default availabilitySync;

@ -61,6 +61,7 @@ export interface DVRSettings {
externalUrl?: string; externalUrl?: string;
syncEnabled: boolean; syncEnabled: boolean;
preventSearch: boolean; preventSearch: boolean;
tagRequests: boolean;
} }
export interface RadarrSettings extends DVRSettings { export interface RadarrSettings extends DVRSettings {
@ -248,7 +249,8 @@ export type JobId =
| 'sonarr-scan' | 'sonarr-scan'
| 'download-sync' | 'download-sync'
| 'download-sync-reset' | 'download-sync-reset'
| 'image-cache-cleanup'; | 'image-cache-cleanup'
| 'availability-sync';
interface AllSettings { interface AllSettings {
clientId: string; clientId: string;
@ -409,6 +411,9 @@ class Settings {
'sonarr-scan': { 'sonarr-scan': {
schedule: '0 30 4 * * *', schedule: '0 30 4 * * *',
}, },
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': { 'download-sync': {
schedule: '0 * * * * *', schedule: '0 * * * * *',
}, },
@ -546,7 +551,7 @@ class Settings {
} }
private generateApiKey(): string { private generateApiKey(): string {
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64'); return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
} }
private generateVapidKeys(force = false): void { private generateVapidKeys(force = false): void {

@ -0,0 +1,6 @@
const clearCookies: Middleware = (_req, res, next) => {
res.removeHeader('Set-Cookie');
next();
};
export default clearCookies;

@ -1,4 +1,5 @@
import type { import type {
TmdbCollectionResult,
TmdbMovieDetails, TmdbMovieDetails,
TmdbMovieResult, TmdbMovieResult,
TmdbPersonDetails, TmdbPersonDetails,
@ -9,7 +10,7 @@ import type {
import { MediaType as MainMediaType } from '@server/constants/media'; import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media'; import type Media from '@server/entity/Media';
export type MediaType = 'tv' | 'movie' | 'person'; export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
interface SearchResult { interface SearchResult {
id: number; id: number;
@ -43,6 +44,18 @@ export interface TvResult extends SearchResult {
firstAirDate: string; firstAirDate: string;
} }
export interface CollectionResult {
id: number;
mediaType: 'collection';
title: string;
originalTitle: string;
adult: boolean;
posterPath?: string;
backdropPath?: string;
overview: string;
originalLanguage: string;
}
export interface PersonResult { export interface PersonResult {
id: number; id: number;
name: string; name: string;
@ -53,7 +66,7 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[]; knownFor: (MovieResult | TvResult)[];
} }
export type Results = MovieResult | TvResult | PersonResult; export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
export const mapMovieResult = ( export const mapMovieResult = (
movieResult: TmdbMovieResult, movieResult: TmdbMovieResult,
@ -99,6 +112,20 @@ export const mapTvResult = (
mediaInfo: media, mediaInfo: media,
}); });
export const mapCollectionResult = (
collectionResult: TmdbCollectionResult
): CollectionResult => ({
id: collectionResult.id,
mediaType: collectionResult.media_type || 'collection',
adult: collectionResult.adult,
originalLanguage: collectionResult.original_language,
originalTitle: collectionResult.original_title,
title: collectionResult.title,
overview: collectionResult.overview,
backdropPath: collectionResult.backdrop_path,
posterPath: collectionResult.poster_path,
});
export const mapPersonResult = ( export const mapPersonResult = (
personResult: TmdbPersonResult personResult: TmdbPersonResult
): PersonResult => ({ ): PersonResult => ({
@ -118,7 +145,12 @@ export const mapPersonResult = (
}); });
export const mapSearchResults = ( export const mapSearchResults = (
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[], results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[],
media?: Media[] media?: Media[]
): Results[] => ): Results[] =>
results.map((result) => { results.map((result) => {
@ -139,6 +171,8 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.TV req.tmdbId === result.id && req.mediaType === MainMediaType.TV
) )
); );
case 'collection':
return mapCollectionResult(result);
default: default:
return mapPersonResult(result); return mapPersonResult(result);
} }

@ -14,12 +14,13 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie'; import { mapProductionCompany } from '@server/models/Movie';
import { import {
mapCollectionResult,
mapMovieResult, mapMovieResult,
mapPersonResult, mapPersonResult,
mapTvResult, mapTvResult,
} from '@server/models/Search'; } from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv'; import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers'; import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express'; import { Router } from 'express';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { z } from 'zod'; import { z } from 'zod';
@ -64,6 +65,8 @@ const QueryFilterOptions = z.object({
withRuntimeLte: z.coerce.string().optional(), withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(), voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(), voteAverageLte: z.coerce.string().optional(),
voteCountGte: z.coerce.string().optional(),
voteCountLte: z.coerce.string().optional(),
network: z.coerce.string().optional(), network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(), watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(), watchRegion: z.coerce.string().optional(),
@ -95,6 +98,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte, withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte, voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte, voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders, watchProviders: query.watchProviders,
watchRegion: query.watchRegion, watchRegion: query.watchRegion,
}); });
@ -370,6 +375,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte, withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte, voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte, voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders, watchProviders: query.watchProviders,
watchRegion: query.watchRegion, watchRegion: query.watchRegion,
}); });
@ -647,6 +654,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
) )
: isPerson(result) : isPerson(result)
? mapPersonResult(result) ? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult( : mapTvResult(
result, result,
media.find( media.find(
@ -800,12 +809,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
} }
); );
discoverRoutes.get<{ page?: number }, WatchlistResponse>( discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist', '/watchlist',
async (req, res) => { async (req, res) => {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const itemsPerPage = 20; const itemsPerPage = 20;
const page = req.params.page ?? 1; const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage; const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({ const activeUser = await userRepository.findOne({
@ -829,8 +838,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
return res.json({ return res.json({
page, page,
totalPages: Math.ceil(watchlist.size / itemsPerPage), totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.size, totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({ results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
title: item.title, title: item.title,

@ -183,9 +183,7 @@ serviceRoutes.get<{ tmdbId: string }>(
const sonarr = new SonarrAPI({ const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey, apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
}); });
try { try {

@ -381,7 +381,14 @@ router.delete<{ id: string }>(
* we manually remove all requests from the user here so the parent media's * we manually remove all requests from the user here so the parent media's
* properly reflect the change. * properly reflect the change.
*/ */
await requestRepository.remove(user.requests); await requestRepository.remove(user.requests, {
/**
* Break-up into groups of 1000 requests to be removed at a time.
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
* https://typeorm.io/repository-api#additional-options
*/
chunk: user.requests.length / 1000,
});
await userRepository.delete(user.id); await userRepository.delete(user.id);
return res.status(200).json(user.filter()); return res.status(200).json(user.filter());
@ -607,7 +614,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
} }
); );
router.get<{ id: string; page?: number }, WatchlistResponse>( router.get<{ id: string }, WatchlistResponse>(
'/:id/watchlist', '/:id/watchlist',
async (req, res, next) => { async (req, res, next) => {
if ( if (
@ -627,7 +634,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
} }
const itemsPerPage = 20; const itemsPerPage = 20;
const page = req.params.page ?? 1; const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage; const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({ const user = await getRepository(User).findOneOrFail({
@ -651,8 +658,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
return res.json({ return res.json({
page, page,
totalPages: Math.ceil(watchlist.size / itemsPerPage), totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.size, totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({ results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
title: item.title, title: item.title,

@ -1,4 +1,5 @@
import type { import type {
TmdbCollectionResult,
TmdbMovieDetails, TmdbMovieDetails,
TmdbMovieResult, TmdbMovieResult,
TmdbPersonDetails, TmdbPersonDetails,
@ -8,17 +9,35 @@ import type {
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
export const isMovie = ( export const isMovie = (
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult movie:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): movie is TmdbMovieResult => { ): movie is TmdbMovieResult => {
return (movie as TmdbMovieResult).title !== undefined; return (movie as TmdbMovieResult).title !== undefined;
}; };
export const isPerson = ( export const isPerson = (
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult person:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): person is TmdbPersonResult => { ): person is TmdbPersonResult => {
return (person as TmdbPersonResult).known_for !== undefined; return (person as TmdbPersonResult).known_for !== undefined;
}; };
export const isCollection = (
collection:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): collection is TmdbCollectionResult => {
return (collection as TmdbCollectionResult).media_type === 'collection';
};
export const isMovieDetails = ( export const isMovieDetails = (
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
): movie is TmdbMovieDetails => { ): movie is TmdbMovieDetails => {

@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection'; import type { Collection } from '@server/models/Collection';
@ -39,6 +40,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const [requestModal, setRequestModal] = useState(false); const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false); const [is4k, setIs4k] = useState(false);
const returnCollectionDownloadItems = (data: Collection | undefined) => {
const [downloadStatus, downloadStatus4k] = [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
return { downloadStatus, downloadStatus4k };
};
const { const {
data, data,
error, error,
@ -46,21 +60,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, { } = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
fallbackData: collection, fallbackData: collection,
revalidateOnMount: true, revalidateOnMount: true,
refreshInterval: refreshIntervalHelper(
returnCollectionDownloadItems(collection),
15000
),
}); });
const { data: genres } = const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`); useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
const [downloadStatus, downloadStatus4k] = useMemo(() => { const [downloadStatus, downloadStatus4k] = useMemo(() => {
return [ const downloadItems = returnCollectionDownloadItems(data);
data?.parts.flatMap((item) => return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : [] }, [data]);
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
}, [data?.parts]);
const [titles, titles4k] = useMemo(() => { const [titles, titles4k] = useMemo(() => {
return [ return [
@ -336,7 +348,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
/> />
))} ))}
/> />
<div className="pb-8" /> <div className="extra-bottom-space relative" />
</div> </div>
); );
}; };

@ -71,7 +71,7 @@ const Badge = (
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100' 'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
); );
if (href) { if (href) {
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100'); badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
} }
} }

@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
<Transition <Transition
as={Fragment} as={Fragment}
show={isOpen} show={isOpen}
enter="transition ease-out duration-100 opacity-0" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg"> <div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
<div <div

@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { import type {
CollectionResult,
MovieResult, MovieResult,
PersonResult, PersonResult,
TvResult, TvResult,
@ -12,7 +13,7 @@ import type {
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
type ListViewProps = { type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult)[]; items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
plexItems?: WatchlistItem[]; plexItems?: WatchlistItem[];
isEmpty?: boolean; isEmpty?: boolean;
isLoading?: boolean; isLoading?: boolean;
@ -90,6 +91,18 @@ const ListView = ({
/> />
); );
break; break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person': case 'person':
titleCard = ( titleCard = (
<PersonCard <PersonCard

@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
appear appear
as="div" as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70" className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition opacity-0 duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition opacity-100 duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
ref={parentRef} ref={parentRef}
@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition <Transition
appear appear
as={Fragment} as={Fragment}
enter="transition opacity-0 duration-300 transform scale-75" enter="transition duration-300"
enterFrom="opacity-0 scale-75" enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={loading} show={loading}
@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div> </div>
</Transition> </Transition>
<Transition <Transition
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle" className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-headline" aria-labelledby="modal-headline"
@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
}} }}
appear appear
as="div" as="div"
enter="transition opacity-0 duration-300 transform scale-75" enter="transition duration-300"
enterFrom="opacity-0 scale-75" enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={!loading} show={!loading}

@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
checked ? 'translate-x-5' : 'translate-x-0' checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
); );

@ -37,10 +37,10 @@ const SlideOver = ({
as={Fragment} as={Fragment}
show={show} show={show}
appear appear
enter="opacity-0 transition ease-in-out duration-300" enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition ease-in-out duration-300" leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
@ -58,16 +58,16 @@ const SlideOver = ({
<section className="absolute inset-y-0 right-0 flex max-w-full"> <section className="absolute inset-y-0 right-0 flex max-w-full">
<Transition.Child <Transition.Child
appear appear
enter="transform transition ease-in-out duration-500 sm:duration-700" enter="transition-transform ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full" enterFrom="translate-x-full"
enterTo="translate-x-0" enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700" leave="transition-transform ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="translate-x-full" leaveTo="translate-x-full"
> >
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div <div
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4" className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
ref={slideoverRef} ref={slideoverRef}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >

@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants'; import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider'; import MediaSlider from '@app/components/MediaSlider';
import { WatchProviderSelector } from '@app/components/Selector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type { import type {
TmdbCompanySearchResponse, TmdbCompanySearchResponse,
@ -55,7 +56,7 @@ type CreateOption = {
dataUrl: string; dataUrl: string;
params?: string; params?: string;
titlePlaceholderText: string; titlePlaceholderText: string;
dataPlaceholderText: string; dataPlaceholderText?: string;
}; };
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder), titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch), dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
}, },
{
type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices),
dataUrl: '/api/v1/discover/movies',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
{
type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices),
dataUrl: '/api/v1/discover/tv',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
]; ];
return ( return (
@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
/> />
); );
break; break;
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'movie'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'tv'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
default: default:
dataInput = ( dataInput = (
<Field <Field
@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
'$value', '$value',
encodeURIExtraParams(values.data) encodeURIExtraParams(values.data)
)} )}
extraParams={activeOption.params?.replace( extraParams={
'$value', activeOption.type ===
encodeURIExtraParams(values.data) DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES ||
)} activeOption.type ===
DiscoverSliderType.TMDB_TV_STREAMING_SERVICES
? activeOption.params
?.replace(
'$regionValue',
encodeURIExtraParams(values?.data.split(',')[0])
)
.replace(
'$providersValue',
encodeURIExtraParams(values?.data.split(',')[1])
)
: activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)
}
onNewTitles={updateResultCount} onNewTitles={updateResultCount}
/> />
</div> </div>

@ -164,6 +164,10 @@ const DiscoverSliderEdit = ({
return intl.formatMessage(sliderTitles.tmdbnetwork); return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH: case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch); return intl.formatMessage(sliderTitles.tmdbsearch);
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
default: default:
return 'Unknown Slider'; return 'Unknown Slider';
} }
@ -195,7 +199,9 @@ const DiscoverSliderEdit = ({
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`} className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
> >
<Bars3Icon className="h-6 w-6" /> <Bars3Icon className="h-6 w-6" />
<div>{getSliderTitle(slider)}</div> <div className="w-7/12 truncate md:w-full">
{getSliderTitle(slider)}
</div>
</div> </div>
<div <div
className={`pointer-events-none ${ className={`pointer-events-none ${

@ -35,8 +35,10 @@ const messages = defineMessages({
ratingText: 'Ratings between {minValue} and {maxValue}', ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters', clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score', tmdbuserscore: 'TMDB User Score',
tmdbuservotecount: 'TMDB User Vote Count',
runtime: 'Runtime', runtime: 'Runtime',
streamingservices: 'Streaming Services', streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}',
}); });
type FilterSlideoverProps = { type FilterSlideoverProps = {
@ -246,6 +248,45 @@ const FilterSlideover = ({
})} })}
/> />
</div> </div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuservotecount)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={1000}
defaultMaxValue={
currentFilters.voteCountLte
? Number(currentFilters.voteCountLte)
: undefined
}
defaultMinValue={
currentFilters.voteCountGte
? Number(currentFilters.voteCountGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteCountGte',
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteCountLte',
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.voteCount, {
minValue: currentFilters.voteCountGte ?? 0,
maxValue: currentFilters.voteCountLte ?? 1000,
})}
/>
</div>
<span className="text-lg font-semibold"> <span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)} {intl.formatMessage(messages.streamingservices)}
</span> </span>

@ -86,6 +86,8 @@ export const sliderTitles = defineMessages({
tmdbnetwork: 'TMDB Network', tmdbnetwork: 'TMDB Network',
tmdbstudio: 'TMDB Studio', tmdbstudio: 'TMDB Studio',
tmdbsearch: 'TMDB Search', tmdbsearch: 'TMDB Search',
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
}); });
export const QueryFilterOptions = z.object({ export const QueryFilterOptions = z.object({
@ -102,6 +104,8 @@ export const QueryFilterOptions = z.object({
withRuntimeLte: z.string().optional(), withRuntimeLte: z.string().optional(),
voteAverageGte: z.string().optional(), voteAverageGte: z.string().optional(),
voteAverageLte: z.string().optional(), voteAverageLte: z.string().optional(),
voteCountLte: z.string().optional(),
voteCountGte: z.string().optional(),
watchRegion: z.string().optional(), watchRegion: z.string().optional(),
watchProviders: z.string().optional(), watchProviders: z.string().optional(),
}); });
@ -167,6 +171,14 @@ export const prepareFilterValues = (
filterValues.voteAverageLte = values.voteAverageLte; filterValues.voteAverageLte = values.voteAverageLte;
} }
if (values.voteCountGte) {
filterValues.voteCountGte = values.voteCountGte;
}
if (values.voteCountLte) {
filterValues.voteCountLte = values.voteCountLte;
}
if (values.watchProviders) { if (values.watchProviders) {
filterValues.watchProviders = values.watchProviders; filterValues.watchProviders = values.watchProviders;
} }
@ -188,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
delete clonedFilters.voteAverageLte; delete clonedFilters.voteAverageLte;
} }
if (clonedFilters.voteCountGte || filterValues.voteCountLte) {
totalCount += 1;
delete clonedFilters.voteCountGte;
delete clonedFilters.voteCountLte;
}
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) { if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
totalCount += 1; totalCount += 1;
delete clonedFilters.withRuntimeGte; delete clonedFilters.withRuntimeGte;

@ -165,10 +165,10 @@ const Discover = () => {
</Transition> </Transition>
<Transition <Transition
show={isEditing} show={isEditing}
enter="transition transform duration-300" enter="transition duration-300"
enterFrom="opacity-0 translate-y-6" enterFrom="opacity-0 translate-y-6"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition duration-300 transform" leave="transition duration-300"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-6" leaveTo="opacity-0 translate-y-6"
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3" className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
@ -365,6 +365,36 @@ const Discover = () => {
/> />
); );
break; break;
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/movies"
extraParams={`watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
linkUrl={`/discover/movies?watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/tv"
extraParams={`watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
linkUrl={`/discover/tv?watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
/>
);
break;
} }
if (isEditing) { if (isEditing) {

@ -65,10 +65,10 @@ const IssueComment = ({
> >
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition opacity-0 duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition opacity-100 duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={showDeleteModal} show={showDeleteModal}
@ -115,11 +115,11 @@ const IssueComment = ({
as={Fragment} as={Fragment}
show={open} show={open}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Menu.Items <Menu.Items
static static
@ -164,7 +164,7 @@ const IssueComment = ({
</Menu> </Menu>
)} )}
<div <div
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${ className={`absolute top-3 z-10 h-3 w-3 rotate-45 bg-gray-800 shadow ring-1 ring-gray-500 ${
isReversed ? '-left-1' : '-right-1' isReversed ? '-left-1' : '-right-1'
}`} }`}
/> />

@ -57,11 +57,11 @@ const IssueDescription = ({
show={open} show={open}
as="div" as="div"
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Menu.Items <Menu.Items
static static

@ -182,10 +182,10 @@ const IssueDetails = () => {
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} /> <PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
<Transition <Transition
as="div" as="div"
enter="transition opacity-0 duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition opacity-100 duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={showDeleteModal} show={showDeleteModal}

@ -12,10 +12,10 @@ interface IssueModalProps {
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => ( const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
<Transition <Transition
as="div" as="div"
enter="transition opacity-0 duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition opacity-100 duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={show} show={show}

@ -34,12 +34,12 @@ const LanguagePicker = () => {
<Transition <Transition
as="div" as="div"
show={isDropdownOpen} show={isDropdownOpen}
enter="transition ease-out duration-100 opacity-0" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<div <div
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg" className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"

@ -131,13 +131,13 @@ const MobileMenu = () => {
show={isOpen} show={isOpen}
as="div" as="div"
ref={ref} ref={ref}
enter="transition transform duration-500" enter="transition duration-500"
enterFrom="opacity-0 translate-y-0" enterFrom="opacity-0 translate-y-0"
enterTo="opacity-100 -translate-y-full" enterTo="opacity-100 -translate-y-full"
leave="transition duration-500 transform" leave="transition duration-500"
leaveFrom="opacity-100 -translate-y-full" leaveFrom="opacity-100 -translate-y-full"
leaveTo="opacity-0 translate-y-0" leaveTo="opacity-0 translate-y-0"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur" className="absolute top-0 left-0 right-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
> >
{filteredLinks.map((link) => { {filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp); const isActive = router.pathname.match(link.activeRegExp);

@ -0,0 +1,118 @@
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
const PullToRefresh = () => {
const router = useRouter();
const [pullStartPoint, setPullStartPoint] = useState(0);
const [pullChange, setPullChange] = useState(0);
const refreshDiv = useRef<HTMLDivElement>(null);
// Various pull down thresholds that determine icon location
const pullDownInitThreshold = pullChange > 20;
const pullDownStopThreshold = 120;
const pullDownReloadThreshold = pullChange > 340;
const pullDownIconLocation = pullChange / 3;
useEffect(() => {
// Reload function that is called when reload threshold has been hit
// Add loading class to determine when to add spin animation
const forceReload = () => {
refreshDiv.current?.classList.add('loading');
setTimeout(() => {
router.reload();
}, 1000);
};
const html = document.querySelector('html');
// Determines if we are at the top of the page
// Locks or unlocks page when pulling down to refresh
const pullStart = (e: TouchEvent) => {
setPullStartPoint(e.targetTouches[0].screenY);
if (window.scrollY === 0 && window.scrollX === 0) {
refreshDiv.current?.classList.add('block');
refreshDiv.current?.classList.remove('hidden');
document.body.style.touchAction = 'none';
document.body.style.overscrollBehavior = 'none';
if (html) {
html.style.overscrollBehaviorY = 'none';
}
} else {
refreshDiv.current?.classList.remove('block');
refreshDiv.current?.classList.add('hidden');
}
};
// Tracks how far we have pulled down the refresh icon
const pullDown = async (e: TouchEvent) => {
const screenY = e.targetTouches[0].screenY;
const pullLength =
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
setPullChange(pullLength);
};
// Will reload the page if we are past the threshold
// Otherwise, we reset the pull
const pullFinish = () => {
setPullStartPoint(0);
if (pullDownReloadThreshold) {
forceReload();
} else {
setPullChange(0);
}
document.body.style.touchAction = 'auto';
document.body.style.overscrollBehaviorY = 'auto';
if (html) {
html.style.overscrollBehaviorY = 'auto';
}
};
window.addEventListener('touchstart', pullStart, { passive: false });
window.addEventListener('touchmove', pullDown, { passive: false });
window.addEventListener('touchend', pullFinish, { passive: false });
return () => {
window.removeEventListener('touchstart', pullStart);
window.removeEventListener('touchmove', pullDown);
window.removeEventListener('touchend', pullFinish);
};
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
return (
<div
ref={refreshDiv}
className="absolute left-0 right-0 top-0 z-50 m-auto w-fit transition-all ease-out"
id="refreshIcon"
style={{
top:
pullDownIconLocation < pullDownStopThreshold && pullDownInitThreshold
? pullDownIconLocation
: pullDownInitThreshold
? pullDownStopThreshold
: '',
}}
>
<div
className={`${
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
style={{ animationDirection: 'reverse' }}
>
<ArrowPathIcon
className={`rounded-full ${
pullDownReloadThreshold && 'rotate-180'
} text-indigo-500 transition-all duration-300`}
/>
</div>
</div>
);
};
export default PullToRefresh;

@ -71,9 +71,7 @@ const SidebarLinks: SidebarLinkProps[] = [
{ {
href: '/issues', href: '/issues',
messagesKey: 'issues', messagesKey: 'issues',
svgIcon: ( svgIcon: <ExclamationTriangleIcon className="mr-3 h-6 w-6" />,
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
),
activeRegExp: /^\/issues/, activeRegExp: /^\/issues/,
requiredPermission: [ requiredPermission: [
Permission.MANAGE_ISSUES, Permission.MANAGE_ISSUES,
@ -127,10 +125,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
</Transition.Child> </Transition.Child>
<Transition.Child <Transition.Child
as="div" as="div"
enter="transition ease-in-out duration-300 transform" enter="transition-transform ease-in-out duration-300"
enterFrom="-translate-x-full" enterFrom="-translate-x-full"
enterTo="translate-x-0" enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform" leave="transition-transform ease-in-out duration-300"
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="-translate-x-full" leaveTo="-translate-x-full"
> >

@ -63,11 +63,11 @@ const UserDropdown = () => {
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="opacity-0 scale-95"
appear appear
> >
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg"> <Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">

@ -1,8 +1,8 @@
import MobileMenu from '@app/components/Layout/MobileMenu'; import MobileMenu from '@app/components/Layout/MobileMenu';
import PullToRefresh from '@app/components/Layout/PullToRefresh';
import SearchInput from '@app/components/Layout/SearchInput'; import SearchInput from '@app/components/Layout/SearchInput';
import Sidebar from '@app/components/Layout/Sidebar'; import Sidebar from '@app/components/Layout/Sidebar';
import UserDropdown from '@app/components/Layout/UserDropdown'; import UserDropdown from '@app/components/Layout/UserDropdown';
import PullToRefresh from '@app/components/PullToRefresh';
import type { AvailableLocale } from '@app/context/LanguageContext'; import type { AvailableLocale } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';

@ -95,10 +95,10 @@ const Login = () => {
<Transition <Transition
as="div" as="div"
show={!!error} show={!!error}
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >

@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton'; import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver'; import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip';
import DownloadBlock from '@app/components/DownloadBlock'; import DownloadBlock from '@app/components/DownloadBlock';
import IssueBlock from '@app/components/IssueBlock'; import IssueBlock from '@app/components/IssueBlock';
import RequestBlock from '@app/components/RequestBlock'; import RequestBlock from '@app/components/RequestBlock';
@ -144,20 +145,24 @@ const ManageSlideOver = ({
<div className="overflow-hidden rounded-md border border-gray-700 shadow"> <div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul> <ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => ( {data.mediaInfo?.downloadStatus?.map((status, index) => (
<li <Tooltip
key={`dl-status-${status.externalId}-${index}`} key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0" content={status.title}
> >
<DownloadBlock downloadItem={status} /> <li className="border-b border-gray-700 last:border-b-0">
</li> <DownloadBlock downloadItem={status} />
</li>
</Tooltip>
))} ))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => ( {data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li <Tooltip
key={`dl-status-${status.externalId}-${index}`} key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0" content={status.title}
> >
<DownloadBlock downloadItem={status} is4k /> <li className="border-b border-gray-700 last:border-b-0">
</li> <DownloadBlock downloadItem={status} is4k />
</li>
</Tooltip>
))} ))}
</ul> </ul>
</div> </div>

@ -26,6 +26,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers'; import { sortCrewPriority } from '@app/utils/creditHelpers';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { import {
ArrowRightCircleIcon, ArrowRightCircleIcon,
CloudIcon, CloudIcon,
@ -110,6 +111,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
mutate: revalidate, mutate: revalidate,
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, { } = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
fallbackData: movie, fallbackData: movie,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: movie?.mediaInfo?.downloadStatus,
downloadStatus4k: movie?.mediaInfo?.downloadStatus4k,
},
15000
),
}); });
const { data: ratingData } = useSWR<RTRating>( const { data: ratingData } = useSWR<RTRating>(

@ -1,45 +0,0 @@
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/router';
import PR from 'pulltorefreshjs';
import { useEffect } from 'react';
import ReactDOMServer from 'react-dom/server';
const PullToRefresh = () => {
const router = useRouter();
useEffect(() => {
PR.init({
mainElement: '#pull-to-refresh',
onRefresh() {
router.reload();
},
iconArrow: ReactDOMServer.renderToString(
<div className="p-2">
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
iconRefreshing: ReactDOMServer.renderToString(
<div
className="animate-spin p-2"
style={{ animationDirection: 'reverse' }}
>
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />),
instructionsRefreshing: ReactDOMServer.renderToString(<div />),
distReload: 60,
distIgnore: 15,
shouldPullToRefresh: () =>
!window.scrollY && document.body.style.overflow !== 'hidden',
});
return () => {
PR.destroyAll();
};
}, [router]);
return <div id="pull-to-refresh"></div>;
};
export default PullToRefresh;

@ -122,7 +122,7 @@ const RegionSelector = ({
<Transition <Transition
show={open} show={open}
leave="transition ease-in duration-100" leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg" className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"

@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks'; import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { withProperties } from '@app/utils/typeHelpers'; import { withProperties } from '@app/utils/typeHelpers';
import { import {
ArrowPathIcon, ArrowPathIcon,
@ -220,6 +221,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`; : `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}` : null inView ? `${url}` : null
); );
@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
mutate: revalidate, mutate: revalidate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, { } = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
fallbackData: request, fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
}); });
const { plexUrl, plexUrl4k } = useDeepLinks({ const { plexUrl, plexUrl4k } = useDeepLinks({

@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks'; import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { import {
ArrowPathIcon, ArrowPathIcon,
CheckIcon, CheckIcon,
@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
`/api/v1/request/${request.id}`, `/api/v1/request/${request.id}`,
{ {
fallbackData: request, fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
} }
); );

@ -582,10 +582,10 @@ const AdvancedRequester = ({
<Transition <Transition
show={open} show={open}
enter="transition ease-in duration-300" enter="transition-opacity ease-in duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition ease-in duration-100" leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg" className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"

@ -324,7 +324,7 @@ const CollectionRequestModal = ({
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
isAllParts() ? 'translate-x-5' : 'translate-x-0' isAllParts() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</th> </th>
@ -389,7 +389,7 @@ const CollectionRequestModal = ({
isSelectedPart(part.id) isSelectedPart(part.id)
? 'translate-x-5' ? 'translate-x-5'
: 'translate-x-0' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</td> </td>

@ -540,7 +540,7 @@ const TvRequestModal = ({
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
isAllSeasons() ? 'translate-x-5' : 'translate-x-0' isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</th> </th>
@ -631,7 +631,7 @@ const TvRequestModal = ({
isSelectedSeason(season.seasonNumber) isSelectedSeason(season.seasonNumber)
? 'translate-x-5' ? 'translate-x-5'
: 'translate-x-0' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</td> </td>

@ -29,10 +29,10 @@ const RequestModal = ({
return ( return (
<Transition <Transition
as="div" as="div"
enter="transition opacity-0 duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition opacity-100 duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={show} show={show}

@ -169,15 +169,19 @@ export const GenreSelector = ({
loadDefaultGenre(); loadDefaultGenre();
}, [defaultValue, type]); }, [defaultValue, type]);
const loadGenreOptions = async () => { const loadGenreOptions = async (inputValue: string) => {
const results = await axios.get<GenreSliderItem[]>( const results = await axios.get<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}` `/api/v1/discover/genreslider/${type}`
); );
return results.data.map((result) => ({ return results.data
label: result.name, .map((result) => ({
value: result.id, label: result.name,
})); value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
}; };
return ( return (
@ -305,7 +309,9 @@ export const WatchProviderSelector = ({
useEffect(() => { useEffect(() => {
onChange(watchRegion, activeProvider); onChange(watchRegion, activeProvider);
}, [activeProvider, watchRegion, onChange]); // removed onChange as a dependency as we only need to call it when the value(s) change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProvider, watchRegion]);
const orderedData = useMemo(() => { const orderedData = useMemo(() => {
if (!data) { if (!data) {
@ -344,7 +350,7 @@ export const WatchProviderSelector = ({
<SmallLoadingSpinner /> <SmallLoadingSpinner />
) : ( ) : (
<div className="grid"> <div className="grid">
<div className="grid grid-cols-6 gap-2"> <div className="provider-icons grid gap-2">
{initialProviders.map((provider) => { {initialProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id); const isActive = activeProvider.includes(provider.id);
return ( return (
@ -353,7 +359,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`} key={`prodiver-${provider.id}`}
> >
<div <div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${ className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
isActive isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500' ? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600' : 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@ -386,7 +392,7 @@ export const WatchProviderSelector = ({
})} })}
</div> </div>
{showMore && otherProviders.length > 0 && ( {showMore && otherProviders.length > 0 && (
<div className="relative top-2 grid grid-cols-6 gap-2"> <div className="provider-icons relative top-2 grid gap-2">
{otherProviders.map((provider) => { {otherProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id); const isActive = activeProvider.includes(provider.id);
return ( return (
@ -395,7 +401,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`} key={`prodiver-${provider.id}`}
> >
<div <div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${ className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
isActive isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500' ? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600' : 'bg-gray-700 ring-gray-500 hover:bg-gray-600'

@ -32,7 +32,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
isEnabled ? 'translate-x-5' : 'translate-x-0' isEnabled ? 'translate-x-5' : 'translate-x-0'
} relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`} } relative inline-block h-5 w-5 rounded-full bg-white shadow transition duration-200 ease-in-out`}
> >
<span <span
className={`${ className={`${

@ -57,6 +57,9 @@ const messages = defineMessages({
testFirstTags: 'Test connection to load tags', testFirstTags: 'Test connection to load tags',
tags: 'Tags', tags: 'Tags',
enableSearch: 'Enable Automatic Search', enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'URL base must have a leading slash', validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
@ -214,10 +217,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
as="div" as="div"
appear appear
show show
enter="transition ease-in-out duration-300 transform opacity-0" enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacuty-100" enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100" leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: radarr?.externalUrl, externalUrl: radarr?.externalUrl,
syncEnabled: radarr?.syncEnabled ?? false, syncEnabled: radarr?.syncEnabled ?? false,
enableSearch: !radarr?.preventSearch, enableSearch: !radarr?.preventSearch,
tagRequests: radarr?.tagRequests ?? false,
}} }}
validationSchema={RadarrSettingsSchema} validationSchema={RadarrSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: values.externalUrl, externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled, syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch, preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
}; };
if (!radarr) { if (!radarr) {
await axios.post('/api/v1/settings/radarr', submission); await axios.post('/api/v1/settings/radarr', submission);
@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
/> />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div> </div>
</Modal> </Modal>
); );

@ -63,10 +63,10 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3"> <div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
<Transition <Transition
as={Fragment} as={Fragment}
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={isModalOpen} show={isModalOpen}

@ -53,6 +53,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'plex-recently-added-scan': 'Plex Recently Added Scan', 'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan', 'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync', 'plex-watchlist-sync': 'Plex Watchlist Sync',
'availability-sync': 'Media Availability Sync',
'radarr-scan': 'Radarr Scan', 'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan', 'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync', 'download-sync': 'Download Sync',
@ -67,6 +68,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}', 'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes: editJobScheduleSelectorMinutes:
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}', 'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
editJobScheduleSelectorSeconds:
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
imagecache: 'Image Cache', imagecache: 'Image Cache',
imagecacheDescription: imagecacheDescription:
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.', 'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
@ -78,7 +81,7 @@ interface Job {
id: JobId; id: JobId;
name: string; name: string;
type: 'process' | 'command'; type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed'; interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string; cronSchedule: string;
nextExecutionTime: string; nextExecutionTime: string;
running: boolean; running: boolean;
@ -89,10 +92,11 @@ type JobModalState = {
job?: Job; job?: Job;
scheduleHours: number; scheduleHours: number;
scheduleMinutes: number; scheduleMinutes: number;
scheduleSeconds: number;
}; };
type JobModalAction = type JobModalAction =
| { type: 'set'; hours?: number; minutes?: number } | { type: 'set'; hours?: number; minutes?: number; seconds?: number }
| { | {
type: 'close'; type: 'close';
} }
@ -115,6 +119,7 @@ const jobModalReducer = (
job: action.job, job: action.job,
scheduleHours: 1, scheduleHours: 1,
scheduleMinutes: 5, scheduleMinutes: 5,
scheduleSeconds: 30,
}; };
case 'set': case 'set':
@ -122,6 +127,7 @@ const jobModalReducer = (
...state, ...state,
scheduleHours: action.hours ?? state.scheduleHours, scheduleHours: action.hours ?? state.scheduleHours,
scheduleMinutes: action.minutes ?? state.scheduleMinutes, scheduleMinutes: action.minutes ?? state.scheduleMinutes,
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
}; };
} }
}; };
@ -149,6 +155,7 @@ const SettingsJobs = () => {
isOpen: false, isOpen: false,
scheduleHours: 1, scheduleHours: 1,
scheduleMinutes: 5, scheduleMinutes: 5,
scheduleSeconds: 30,
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@ -200,9 +207,11 @@ const SettingsJobs = () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*']; const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
try { try {
if (jobModalState.job?.interval === 'short') { if (jobModalState.job?.interval === 'seconds') {
jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
} else if (jobModalState.job?.interval === 'minutes') {
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`; jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
} else if (jobModalState.job?.interval === 'long') { } else if (jobModalState.job?.interval === 'hours') {
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`; jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else { } else {
// jobs with interval: fixed should not be editable // jobs with interval: fixed should not be editable
@ -244,10 +253,10 @@ const SettingsJobs = () => {
/> />
<Transition <Transition
as={Fragment} as={Fragment}
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={jobModalState.isOpen} show={jobModalState.isOpen}
@ -286,7 +295,30 @@ const SettingsJobs = () => {
{intl.formatMessage(messages.editJobSchedulePrompt)} {intl.formatMessage(messages.editJobSchedulePrompt)}
</label> </label>
<div className="form-input-area"> <div className="form-input-area">
{jobModalState.job?.interval === 'short' ? ( {jobModalState.job?.interval === 'seconds' ? (
<select
name="jobScheduleSeconds"
className="inline"
value={jobModalState.scheduleSeconds}
onChange={(e) =>
dispatch({
type: 'set',
seconds: Number(e.target.value),
})
}
>
{[30, 45, 60].map((v) => (
<option value={v} key={`jobScheduleSeconds-${v}`}>
{intl.formatMessage(
messages.editJobScheduleSelectorSeconds,
{
jobScheduleSeconds: v,
}
)}
</option>
))}
</select>
) : jobModalState.job?.interval === 'minutes' ? (
<select <select
name="jobScheduleMinutes" name="jobScheduleMinutes"
className="inline" className="inline"

@ -143,10 +143,10 @@ const SettingsLogs = () => {
/> />
<Transition <Transition
as={Fragment} as={Fragment}
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
appear appear

@ -247,10 +247,10 @@ const SettingsServices = () => {
<Transition <Transition
as={Fragment} as={Fragment}
show={deleteServerModal.open} show={deleteServerModal.open}
enter="transition ease-in-out duration-300 transform opacity-0" enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacuty-100" enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100" leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >

@ -62,6 +62,9 @@ const messages = defineMessages({
syncEnabled: 'Enable Scan', syncEnabled: 'Enable Scan',
externalUrl: 'External URL', externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search', enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash', validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
@ -223,10 +226,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
as="div" as="div"
appear appear
show show
enter="transition ease-in-out duration-300 transform opacity-0" enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacuty-100" enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100" leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
@ -252,6 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: sonarr?.externalUrl, externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false, syncEnabled: sonarr?.syncEnabled ?? false,
enableSearch: !sonarr?.preventSearch, enableSearch: !sonarr?.preventSearch,
tagRequests: sonarr?.tagRequests ?? false,
}} }}
validationSchema={SonarrSettingsSchema} validationSchema={SonarrSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
@ -292,6 +296,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: values.externalUrl, externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled, syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch, preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
}; };
if (!sonarr) { if (!sonarr) {
await axios.post('/api/v1/settings/sonarr', submission); await axios.post('/api/v1/settings/sonarr', submission);
@ -960,6 +965,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
/> />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div> </div>
</Modal> </Modal>
); );

@ -44,10 +44,10 @@ const StatusChecker = () => {
return ( return (
<Transition <Transition
as={Fragment} as={Fragment}
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
appear appear

@ -23,7 +23,7 @@ interface TitleCardProps {
summary?: string; summary?: string;
year?: string; year?: string;
title: string; title: string;
userScore: number; userScore?: number;
mediaType: MediaType; mediaType: MediaType;
status?: MediaStatus; status?: MediaStatus;
canExpand?: boolean; canExpand?: boolean;
@ -73,7 +73,9 @@ const TitleCard = ({
const showRequestButton = hasPermission( const showRequestButton = hasPermission(
[ [
Permission.REQUEST, Permission.REQUEST,
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV, mediaType === 'movie' || mediaType === 'collection'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
], ],
{ type: 'or' } { type: 'or' }
); );
@ -86,7 +88,13 @@ const TitleCard = ({
<RequestModal <RequestModal
tmdbId={id} tmdbId={id}
show={showRequestModal} show={showRequestModal}
type={mediaType === 'movie' ? 'movie' : 'tv'} type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
}
onComplete={requestComplete} onComplete={requestComplete}
onUpdating={requestUpdating} onUpdating={requestUpdating}
onCancel={closeModal} onCancel={closeModal}
@ -130,7 +138,7 @@ const TitleCard = ({
<div className="absolute left-0 right-0 flex items-center justify-between p-2"> <div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div <div
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${ className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
mediaType === 'movie' mediaType === 'movie' || mediaType === 'collection'
? 'border-blue-500 bg-blue-600' ? 'border-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600' : 'border-purple-600 bg-purple-600'
}`} }`}
@ -138,10 +146,12 @@ const TitleCard = ({
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5"> <div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{mediaType === 'movie' {mediaType === 'movie'
? intl.formatMessage(globalMessages.movie) ? intl.formatMessage(globalMessages.movie)
: mediaType === 'collection'
? intl.formatMessage(globalMessages.collection)
: intl.formatMessage(globalMessages.tvshow)} : intl.formatMessage(globalMessages.tvshow)}
</div> </div>
</div> </div>
{currentStatus && ( {currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
<div className="pointer-events-none z-40 flex items-center"> <div className="pointer-events-none z-40 flex items-center">
<StatusBadgeMini <StatusBadgeMini
status={currentStatus} status={currentStatus}
@ -154,10 +164,10 @@ const TitleCard = ({
<Transition <Transition
as={Fragment} as={Fragment}
show={isUpdating} show={isUpdating}
enter="transition ease-in-out duration-300 transform opacity-0" enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100" leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
@ -169,15 +179,23 @@ const TitleCard = ({
<Transition <Transition
as={Fragment} as={Fragment}
show={!image || showDetail || showRequestModal} show={!image || showDetail || showRequestModal}
enter="transition transform opacity-0" enter="transition-opacity"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition transform opacity-100" leave="transition-opacity"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="absolute inset-0 overflow-hidden rounded-xl"> <div className="absolute inset-0 overflow-hidden rounded-xl">
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}> <Link
href={
mediaType === 'movie'
? `/movie/${id}`
: mediaType === 'collection'
? `/collection/${id}`
: `/tv/${id}`
}
>
<a <a
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left" className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
style={{ style={{

@ -30,6 +30,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers'; import { sortCrewPriority } from '@app/utils/creditHelpers';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { Disclosure, Transition } from '@headlessui/react'; import { Disclosure, Transition } from '@headlessui/react';
import { import {
ArrowRightCircleIcon, ArrowRightCircleIcon,
@ -109,6 +110,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
mutate: revalidate, mutate: revalidate,
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, { } = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
fallbackData: tv, fallbackData: tv,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: tv?.mediaInfo?.downloadStatus,
downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
},
15000
),
}); });
const { data: ratingData } = useSWR<RTRating>( const { data: ratingData } = useSWR<RTRating>(
@ -727,18 +735,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)} )}
<ChevronDownIcon <ChevronDownIcon
className={`${ className={`${
open ? 'rotate-180 transform' : '' open ? 'rotate-180' : ''
} h-6 w-6 text-gray-500`} } h-6 w-6 text-gray-500`}
/> />
</Disclosure.Button> </Disclosure.Button>
<Transition <Transition
show={open} show={open}
enter="transition duration-100 ease-out" enter="transition-opacity duration-100 ease-out"
enterFrom="transform opacity-0" enterFrom="opacity-0"
enterTo="transform opacity-100" enterTo="opacity-100"
leave="transition duration-75 ease-out" leave="transition-opacity duration-75 ease-out"
leaveFrom="transform opacity-100" leaveFrom="opacity-100"
leaveTo="transform opacity-0" leaveTo="opacity-0"
// Not sure why this transition is adding a margin without this here // Not sure why this transition is adding a margin without this here
style={{ margin: '0px' }} style={{ margin: '0px' }}
> >

@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0' isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</th> </th>
@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
isSelectedUser(user.id) isSelectedUser(user.id)
? 'translate-x-5' ? 'translate-x-5'
: 'translate-x-0' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</td> </td>

@ -228,10 +228,10 @@ const UserList = () => {
<PageTitle title={intl.formatMessage(messages.users)} /> <PageTitle title={intl.formatMessage(messages.users)} />
<Transition <Transition
as="div" as="div"
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={deleteModal.isOpen} show={deleteModal.isOpen}
@ -257,10 +257,10 @@ const UserList = () => {
<Transition <Transition
as="div" as="div"
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={createModal.isOpen} show={createModal.isOpen}
@ -440,10 +440,10 @@ const UserList = () => {
<Transition <Transition
as="div" as="div"
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={showBulkEditModal} show={showBulkEditModal}
@ -461,10 +461,10 @@ const UserList = () => {
<Transition <Transition
as="div" as="div"
enter="opacity-0 transition duration-300" enter="transition-opacity duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="opacity-100 transition duration-300" leave="transition-opacity duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={showImportModal} show={showImportModal}

@ -15,13 +15,20 @@ export const useLockBodyScroll = (
disabled?: boolean disabled?: boolean
): void => { ): void => {
useEffect(() => { useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow; const originalOverflowStyle = window.getComputedStyle(
document.body
).overflow;
const originalTouchActionStyle = window.getComputedStyle(
document.body
).touchAction;
if (isLocked && !disabled) { if (isLocked && !disabled) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
} }
return () => { return () => {
if (!disabled) { if (!disabled) {
document.body.style.overflow = originalStyle; document.body.style.overflow = originalOverflowStyle;
document.body.style.touchAction = originalTouchActionStyle;
} }
}; };
}, [isLocked, disabled]); }, [isLocked, disabled]);

@ -16,6 +16,7 @@ const globalMessages = defineMessages({
approved: 'Approved', approved: 'Approved',
movie: 'Movie', movie: 'Movie',
movies: 'Movies', movies: 'Movies',
collection: 'Collection',
tvshow: 'Series', tvshow: 'Series',
tvshows: 'Series', tvshows: 'Series',
cancel: 'Cancel', cancel: 'Cancel',

@ -76,7 +76,9 @@
"components.Discover.FilterSlideover.streamingservices": "Streaming Services", "components.Discover.FilterSlideover.streamingservices": "Streaming Services",
"components.Discover.FilterSlideover.studio": "Studio", "components.Discover.FilterSlideover.studio": "Studio",
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score", "components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
"components.Discover.FilterSlideover.tmdbuservotecount": "TMDB User Vote Count",
"components.Discover.FilterSlideover.to": "To", "components.Discover.FilterSlideover.to": "To",
"components.Discover.FilterSlideover.voteCount": "Number of votes between {minValue} and {maxValue}",
"components.Discover.MovieGenreList.moviegenres": "Movie Genres", "components.Discover.MovieGenreList.moviegenres": "Movie Genres",
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
"components.Discover.NetworkSlider.networks": "Networks", "components.Discover.NetworkSlider.networks": "Networks",
@ -105,11 +107,13 @@
"components.Discover.studios": "Studios", "components.Discover.studios": "Studios",
"components.Discover.tmdbmoviegenre": "TMDB Movie Genre", "components.Discover.tmdbmoviegenre": "TMDB Movie Genre",
"components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword", "components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword",
"components.Discover.tmdbmoviestreamingservices": "TMDB Movie Streaming Services",
"components.Discover.tmdbnetwork": "TMDB Network", "components.Discover.tmdbnetwork": "TMDB Network",
"components.Discover.tmdbsearch": "TMDB Search", "components.Discover.tmdbsearch": "TMDB Search",
"components.Discover.tmdbstudio": "TMDB Studio", "components.Discover.tmdbstudio": "TMDB Studio",
"components.Discover.tmdbtvgenre": "TMDB Series Genre", "components.Discover.tmdbtvgenre": "TMDB Series Genre",
"components.Discover.tmdbtvkeyword": "TMDB Series Keyword", "components.Discover.tmdbtvkeyword": "TMDB Series Keyword",
"components.Discover.tmdbtvstreamingservices": "TMDB TV Streaming Services",
"components.Discover.trending": "Trending", "components.Discover.trending": "Trending",
"components.Discover.tvgenres": "Series Genres", "components.Discover.tvgenres": "Series Genres",
"components.Discover.upcoming": "Upcoming Movies", "components.Discover.upcoming": "Upcoming Movies",
@ -680,6 +684,8 @@
"components.Settings.RadarrModal.servername": "Server Name", "components.Settings.RadarrModal.servername": "Server Name",
"components.Settings.RadarrModal.ssl": "Use SSL", "components.Settings.RadarrModal.ssl": "Use SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Scan", "components.Settings.RadarrModal.syncEnabled": "Enable Scan",
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
"components.Settings.RadarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
"components.Settings.RadarrModal.tags": "Tags", "components.Settings.RadarrModal.tags": "Tags",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders", "components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
@ -721,6 +727,7 @@
"components.Settings.SettingsAbout.totalrequests": "Total Requests", "components.Settings.SettingsAbout.totalrequests": "Total Requests",
"components.Settings.SettingsAbout.uptodate": "Up to Date", "components.Settings.SettingsAbout.uptodate": "Up to Date",
"components.Settings.SettingsAbout.version": "Version", "components.Settings.SettingsAbout.version": "Version",
"components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync",
"components.Settings.SettingsJobsCache.cache": "Cache", "components.Settings.SettingsJobsCache.cache": "Cache",
"components.Settings.SettingsJobsCache.cacheDescription": "Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.", "components.Settings.SettingsJobsCache.cacheDescription": "Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.", "components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
@ -739,6 +746,7 @@
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency", "components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}", "components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}", "components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache", "components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup", "components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
"components.Settings.SettingsJobsCache.imagecache": "Image Cache", "components.Settings.SettingsJobsCache.imagecache": "Image Cache",
@ -855,6 +863,8 @@
"components.Settings.SonarrModal.servername": "Server Name", "components.Settings.SonarrModal.servername": "Server Name",
"components.Settings.SonarrModal.ssl": "Use SSL", "components.Settings.SonarrModal.ssl": "Use SSL",
"components.Settings.SonarrModal.syncEnabled": "Enable Scan", "components.Settings.SonarrModal.syncEnabled": "Enable Scan",
"components.Settings.SonarrModal.tagRequests": "Tag Requests",
"components.Settings.SonarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
"components.Settings.SonarrModal.tags": "Tags", "components.Settings.SonarrModal.tags": "Tags",
"components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles", "components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
@ -1172,6 +1182,7 @@
"i18n.cancel": "Cancel", "i18n.cancel": "Cancel",
"i18n.canceling": "Canceling…", "i18n.canceling": "Canceling…",
"i18n.close": "Close", "i18n.close": "Close",
"i18n.collection": "Collection",
"i18n.decline": "Decline", "i18n.decline": "Decline",
"i18n.declined": "Declined", "i18n.declined": "Declined",
"i18n.delete": "Delete", "i18n.delete": "Delete",

@ -1,7 +1,7 @@
import MovieDetails from '@app/components/MovieDetails'; import MovieDetails from '@app/components/MovieDetails';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import axios from 'axios'; import axios from 'axios';
import type { NextPage } from 'next'; import type { GetServerSideProps, NextPage } from 'next';
interface MoviePageProps { interface MoviePageProps {
movie?: MovieDetailsType; movie?: MovieDetailsType;
@ -11,25 +11,25 @@ const MoviePage: NextPage<MoviePageProps> = ({ movie }) => {
return <MovieDetails movie={movie} />; return <MovieDetails movie={movie} />;
}; };
MoviePage.getInitialProps = async (ctx) => { export const getServerSideProps: GetServerSideProps<MoviePageProps> = async (
if (ctx.req) { ctx
const response = await axios.get<MovieDetailsType>( ) => {
`http://localhost:${process.env.PORT || 5055}/api/v1/movie/${ const response = await axios.get<MovieDetailsType>(
ctx.query.movieId `http://localhost:${process.env.PORT || 5055}/api/v1/movie/${
}`, ctx.query.movieId
{ }`,
headers: ctx.req?.headers?.cookie {
? { cookie: ctx.req.headers.cookie } headers: ctx.req?.headers?.cookie
: undefined, ? { cookie: ctx.req.headers.cookie }
} : undefined,
); }
);
return { return {
props: {
movie: response.data, movie: response.data,
}; },
} };
return {};
}; };
export default MoviePage; export default MoviePage;

@ -1,7 +1,7 @@
import TvDetails from '@app/components/TvDetails'; import TvDetails from '@app/components/TvDetails';
import type { TvDetails as TvDetailsType } from '@server/models/Tv'; import type { TvDetails as TvDetailsType } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import type { NextPage } from 'next'; import type { GetServerSideProps, NextPage } from 'next';
interface TvPageProps { interface TvPageProps {
tv?: TvDetailsType; tv?: TvDetailsType;
@ -11,25 +11,23 @@ const TvPage: NextPage<TvPageProps> = ({ tv }) => {
return <TvDetails tv={tv} />; return <TvDetails tv={tv} />;
}; };
TvPage.getInitialProps = async (ctx) => { export const getServerSideProps: GetServerSideProps<TvPageProps> = async (
if (ctx.req) { ctx
const response = await axios.get<TvDetailsType>( ) => {
`http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ const response = await axios.get<TvDetailsType>(
ctx.query.tvId `http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ctx.query.tvId}`,
}`, {
{ headers: ctx.req?.headers?.cookie
headers: ctx.req?.headers?.cookie ? { cookie: ctx.req.headers.cookie }
? { cookie: ctx.req.headers.cookie } : undefined,
: undefined, }
} );
);
return { return {
props: {
tv: response.data, tv: response.data,
}; },
} };
return {};
}; };
export default TvPage; export default TvPage;

@ -17,7 +17,7 @@
body { body {
@apply bg-gray-900; @apply bg-gray-900;
overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch;
} }
code { code {
@ -43,8 +43,8 @@
} }
.slideover { .slideover {
padding-top: calc(1rem + env(safe-area-inset-top)) !important; padding-top: calc(0.75rem + env(safe-area-inset-top)) !important;
padding-bottom: calc(1rem + env(safe-area-inset-top)) !important; padding-bottom: calc(0.75rem + env(safe-area-inset-top)) !important;
} }
.sidebar-close-button { .sidebar-close-button {
@ -73,6 +73,10 @@
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
} }
.provider-icons {
grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr));
}
.slider-header { .slider-header {
@apply relative mt-6 mb-4 flex; @apply relative mt-6 mb-4 flex;
} }

@ -0,0 +1,18 @@
import type { DownloadingItem } from '@server/lib/downloadtracker';
export const refreshIntervalHelper = (
downloadItem: {
downloadStatus: DownloadingItem[] | undefined;
downloadStatus4k: DownloadingItem[] | undefined;
},
timer: number
) => {
if (
(downloadItem.downloadStatus ?? []).length > 0 ||
(downloadItem.downloadStatus4k ?? []).length > 0
) {
return timer;
} else {
return 0;
}
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save