Compare commits
30 Commits
feature/wa
...
feat/hide-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30275450cc | ||
|
|
feffccaca8 | ||
|
|
889caaa733 | ||
|
|
4d56320870 | ||
|
|
1a0053221b | ||
|
|
b925857dfa | ||
|
|
c4aa08f5f0 | ||
|
|
5d73bc2238 | ||
|
|
095048d94a | ||
|
|
98028bf2f4 | ||
|
|
baf1ea95a3 | ||
|
|
23409e6f2f | ||
|
|
dd28200040 | ||
|
|
22360f3b87 | ||
|
|
815d709bcf | ||
|
|
8a2acb7f2b | ||
|
|
67f3a3829e | ||
|
|
f5e5016ca5 | ||
|
|
6e60a275c7 | ||
|
|
3b2633812b | ||
|
|
507227aa49 | ||
|
|
29ab178fb0 | ||
|
|
f5e6b620c1 | ||
|
|
0839718806 | ||
|
|
950b1712b7 | ||
|
|
43a9067976 | ||
|
|
c6a133d4e5 | ||
|
|
4b855b8114 | ||
|
|
6c0fd40877 | ||
|
|
301f2bf7ab |
@@ -38,6 +38,10 @@ module.exports = {
|
||||
prefer: 'type-imports',
|
||||
},
|
||||
],
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
'error',
|
||||
{ allowSameFolder: true },
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
@@ -47,7 +51,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['jsx-a11y', 'react-hooks', 'formatjs'],
|
||||
plugins: ['jsx-a11y', 'react-hooks', 'formatjs', 'no-relative-import-paths'],
|
||||
settings: {
|
||||
react: {
|
||||
pragma: 'React',
|
||||
|
||||
10
.github/workflows/snap.yaml
vendored
10
.github/workflows/snap.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
@@ -40,16 +40,14 @@ jobs:
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
@@ -71,7 +69,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
|
||||
5
.prettierrc.js
Normal file
5
.prettierrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: [require('./merged-prettier-plugin.js')],
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
};
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,5 +15,6 @@
|
||||
"database": "./config/db/db.sqlite3"
|
||||
}
|
||||
],
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const clickFirstTitleCardInSlider = (sliderTitle: string): void => {
|
||||
|
||||
describe('Discover', () => {
|
||||
beforeEach(() => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('loads a trending item', () => {
|
||||
@@ -171,4 +171,40 @@ describe('Discover', () => {
|
||||
.find('[data-testid=request-card-title]')
|
||||
.contains('Movie Not Found');
|
||||
});
|
||||
|
||||
it('loads plex watchlist', () => {
|
||||
cy.intercept('/api/v1/discover/watchlist', {
|
||||
fixture: 'watchlist.json',
|
||||
}).as('getWatchlist');
|
||||
// Wait for one of the watchlist movies to resolve
|
||||
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||
|
||||
cy.visit('/');
|
||||
|
||||
cy.wait('@getWatchlist');
|
||||
|
||||
const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');
|
||||
|
||||
sliderHeader.scrollIntoView();
|
||||
|
||||
cy.wait('@getTmdbMovie');
|
||||
// Wait a little longer to make sure the movie component reloaded
|
||||
cy.wait(500);
|
||||
|
||||
sliderHeader
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.trigger('mouseover')
|
||||
.find('[data-testid=title-card-title]')
|
||||
.invoke('text')
|
||||
.then((text) => {
|
||||
cy.contains('.slider-header', 'Plex Watchlist')
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.click();
|
||||
cy.get('[data-testid=media-title]').should('contain', text);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
describe('Login Page', () => {
|
||||
it('succesfully logs in as an admin', () => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
cy.loginAsAdmin();
|
||||
cy.visit('/');
|
||||
cy.contains('Trending');
|
||||
});
|
||||
|
||||
it('succesfully logs in as a local user', () => {
|
||||
cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD'));
|
||||
cy.loginAsUser();
|
||||
cy.visit('/');
|
||||
cy.contains('Trending');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
describe('Movie Details', () => {
|
||||
it('loads a movie page', () => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
cy.loginAsAdmin();
|
||||
// Try to load minions: rise of gru
|
||||
cy.visit('/movie/438148');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
describe('General Settings', () => {
|
||||
beforeEach(() => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('opens the settings page from the home page', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
describe('TV Details', () => {
|
||||
it('loads a movie page', () => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
it('loads a tv details page', () => {
|
||||
cy.loginAsAdmin();
|
||||
// Try to load stranger things
|
||||
cy.visit('/tv/66732');
|
||||
|
||||
@@ -9,4 +9,20 @@ describe('TV Details', () => {
|
||||
'Stranger Things (2016)'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows seasons and expands episodes', () => {
|
||||
cy.loginAsAdmin();
|
||||
|
||||
// Try to load stranger things
|
||||
cy.visit('/tv/66732');
|
||||
|
||||
// intercept request for season info
|
||||
cy.intercept('/api/v1/tv/66732/season/4').as('season4');
|
||||
|
||||
cy.contains('Season 4').should('be.visible').scrollIntoView().click();
|
||||
|
||||
cy.wait('@season4');
|
||||
|
||||
cy.contains('Chapter Nine').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
const visitUserEditPage = (email: string): void => {
|
||||
cy.visit('/users');
|
||||
|
||||
cy.contains('[data-testid=user-list-row]', email).contains('Edit').click();
|
||||
};
|
||||
|
||||
describe('Auto Request Settings', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('should not see watchlist sync settings on an account without permissions', () => {
|
||||
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||
|
||||
cy.contains('Auto-Request Movies').should('not.exist');
|
||||
cy.contains('Auto-Request Series').should('not.exist');
|
||||
});
|
||||
|
||||
it('should see watchlist sync settings on an admin account', () => {
|
||||
visitUserEditPage(Cypress.env('ADMIN_EMAIL'));
|
||||
|
||||
cy.contains('Auto-Request Movies').should('exist');
|
||||
cy.contains('Auto-Request Series').should('exist');
|
||||
});
|
||||
|
||||
it('should see auto-request settings after being given permission', () => {
|
||||
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||
|
||||
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||
|
||||
cy.get('#autorequest').should('not.be.checked').click();
|
||||
|
||||
cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions');
|
||||
|
||||
cy.contains('Save Changes').click();
|
||||
|
||||
cy.wait('@userPermissions');
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get('#autorequest').should('be.checked');
|
||||
cy.get('#autorequestmovies').should('be.checked');
|
||||
cy.get('#autorequesttv').should('be.checked');
|
||||
|
||||
cy.get('[data-testid=settings-nav-desktop').contains('General').click();
|
||||
|
||||
cy.contains('Auto-Request Movies').should('exist');
|
||||
cy.contains('Auto-Request Series').should('exist');
|
||||
|
||||
cy.get('#watchlistSyncMovies').should('not.be.checked').click();
|
||||
cy.get('#watchlistSyncTv').should('not.be.checked').click();
|
||||
|
||||
cy.intercept('/api/v1/user/*/settings/main').as('userMain');
|
||||
|
||||
cy.contains('Save Changes').click();
|
||||
|
||||
cy.wait('@userMain');
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get('#watchlistSyncMovies').should('be.checked').click();
|
||||
cy.get('#watchlistSyncTv').should('be.checked').click();
|
||||
|
||||
cy.contains('Save Changes').click();
|
||||
|
||||
cy.wait('@userMain');
|
||||
|
||||
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||
|
||||
cy.get('#autorequest').should('be.checked').click();
|
||||
|
||||
cy.contains('Save Changes').click();
|
||||
});
|
||||
});
|
||||
50
cypress/e2e/user/profile.cy.ts
Normal file
50
cypress/e2e/user/profile.cy.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
describe('User Profile', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('opens user profile page from the home page', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=user-menu]').click();
|
||||
cy.get('[data-testid=user-menu-profile]').click();
|
||||
|
||||
cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL'));
|
||||
});
|
||||
|
||||
it('loads plex watchlist', () => {
|
||||
cy.intercept('/api/v1/user/[0-9]*/watchlist', {
|
||||
fixture: 'watchlist.json',
|
||||
}).as('getWatchlist');
|
||||
// Wait for one of the watchlist movies to resolve
|
||||
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||
|
||||
cy.visit('/profile');
|
||||
|
||||
cy.wait('@getWatchlist');
|
||||
|
||||
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
|
||||
|
||||
sliderHeader.scrollIntoView();
|
||||
|
||||
cy.wait('@getTmdbMovie');
|
||||
// Wait a little longer to make sure the movie component reloaded
|
||||
cy.wait(500);
|
||||
|
||||
sliderHeader
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.trigger('mouseover')
|
||||
.find('[data-testid=title-card-title]')
|
||||
.invoke('text')
|
||||
.then((text) => {
|
||||
cy.contains('.slider-header', 'Plex Watchlist')
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.click();
|
||||
cy.get('[data-testid=media-title]').should('contain', text);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const testUser = {
|
||||
|
||||
describe('User List', () => {
|
||||
beforeEach(() => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('opens the user list from the home page', () => {
|
||||
@@ -54,10 +54,7 @@ describe('User List', () => {
|
||||
.contains('Delete')
|
||||
.click();
|
||||
|
||||
cy.get('[data-testid=modal-title]').should(
|
||||
'contain',
|
||||
`Delete ${testUser.displayName}`
|
||||
);
|
||||
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
||||
|
||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||
|
||||
|
||||
25
cypress/fixtures/watchlist.json
Normal file
25
cypress/fixtures/watchlist.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"page": 1,
|
||||
"totalPages": 1,
|
||||
"totalResults": 3,
|
||||
"results": [
|
||||
{
|
||||
"ratingKey": "5d776be17a53e9001e732ab9",
|
||||
"title": "Top Gun: Maverick",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 361743
|
||||
},
|
||||
{
|
||||
"ratingKey": "5e16338fbc1372003ea68ab3",
|
||||
"title": "Nope",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 762504
|
||||
},
|
||||
{
|
||||
"ratingKey": "5f409b8452f200004161e126",
|
||||
"title": "Hocus Pocus 2",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 642885
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,3 +24,11 @@ Cypress.Commands.add('login', (email, password) => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('loginAsAdmin', () => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
});
|
||||
|
||||
Cypress.Commands.add('loginAsUser', () => {
|
||||
cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD'));
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
login(email?: string, password?: string): Chainable<Element>;
|
||||
loginAsAdmin(): Chainable<Element>;
|
||||
loginAsUser(): Chainable<Element>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
merged-prettier-plugin.js
Normal file
21
merged-prettier-plugin.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
const tailwind = require('prettier-plugin-tailwindcss');
|
||||
const organizeImports = require('prettier-plugin-organize-imports');
|
||||
|
||||
const combinedFormatter = {
|
||||
...tailwind,
|
||||
parsers: {
|
||||
...tailwind.parsers,
|
||||
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
...tailwind.parsers[key],
|
||||
preprocess(code, options) {
|
||||
return organizeImports.parsers[key].preprocess(code, options);
|
||||
},
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = combinedFormatter;
|
||||
@@ -121,6 +121,9 @@ components:
|
||||
hideAvailable:
|
||||
type: boolean
|
||||
example: false
|
||||
hideRequested:
|
||||
type: boolean
|
||||
example: false
|
||||
partialRequestsEnabled:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -3512,6 +3515,53 @@ paths:
|
||||
restricted:
|
||||
type: boolean
|
||||
example: false
|
||||
/user/{userId}/watchlist:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
description: |
|
||||
Retrieves a user's Plex Watchlist in a JSON object.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Watchlist data returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: number
|
||||
totalPages:
|
||||
type: number
|
||||
totalResults:
|
||||
type: number
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
tmdbId:
|
||||
type: number
|
||||
example: 1
|
||||
ratingKey:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
/user/{userId}/settings/main:
|
||||
get:
|
||||
summary: Get general settings for a user
|
||||
@@ -4403,6 +4453,46 @@ paths:
|
||||
name:
|
||||
type: string
|
||||
example: Genre Name
|
||||
/discover/watchlist:
|
||||
get:
|
||||
summary: Get the Plex watchlist.
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Watchlist data returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: number
|
||||
totalPages:
|
||||
type: number
|
||||
totalResults:
|
||||
type: number
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
tmdbId:
|
||||
type: number
|
||||
example: 1
|
||||
ratingKey:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
/request:
|
||||
get:
|
||||
summary: Get all requests
|
||||
|
||||
23
package.json
23
package.json
@@ -3,16 +3,16 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates",
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||
"build:next": "next build",
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||
"format": "prettier --loglevel warn --write --cache .",
|
||||
"format:check": "prettier --check --cache .",
|
||||
"typecheck": "yarn typecheck:server && yarn typecheck:client",
|
||||
@@ -20,7 +20,7 @@
|
||||
"typecheck:client": "tsc --noEmit",
|
||||
"prepare": "husky install",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:prepare": "ts-node --files server/scripts/prepareTestDb.ts",
|
||||
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
||||
"cypress:build": "yarn build && yarn cypress:prepare"
|
||||
},
|
||||
"repository": {
|
||||
@@ -32,7 +32,8 @@
|
||||
"@formatjs/intl-displaynames": "6.0.3",
|
||||
"@formatjs/intl-locale": "3.0.3",
|
||||
"@formatjs/intl-pluralrules": "5.0.3",
|
||||
"@headlessui/react": "1.6.6",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@headlessui/react": "^0.0.0-insiders.b301f04",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.3.1",
|
||||
@@ -76,7 +77,6 @@
|
||||
"react-select": "5.4.0",
|
||||
"react-spring": "9.5.2",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
"react-use-clipboard": "1.0.8",
|
||||
"reflect-metadata": "0.1.13",
|
||||
@@ -139,6 +139,7 @@
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-formatjs": "4.1.0",
|
||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.4.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.30.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
@@ -154,6 +155,8 @@
|
||||
"semantic-release-docker-buildx": "1.0.1",
|
||||
"tailwindcss": "3.1.8",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.7.0",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -180,10 +183,6 @@
|
||||
"@commitlint/config-conventional"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
},
|
||||
"release": {
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import xml2js from 'xml2js';
|
||||
import fs, { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../logger';
|
||||
import xml2js from 'xml2js';
|
||||
|
||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cacheManager from '../lib/cache';
|
||||
import logger from '../logger';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface GitHubRelease {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Library, PlexSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import NodePlexAPI from 'plex-api';
|
||||
import type { Library, PlexSettings } from '../lib/settings';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import axios from 'axios';
|
||||
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import xml2js from 'xml2js';
|
||||
import type { PlexDevice } from '../interfaces/api/plexInterfaces';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface PlexAccountResponse {
|
||||
user: PlexUser;
|
||||
@@ -112,20 +112,54 @@ interface UsersResponse {
|
||||
};
|
||||
}
|
||||
|
||||
class PlexTvAPI {
|
||||
interface WatchlistResponse {
|
||||
MediaContainer: {
|
||||
totalSize: number;
|
||||
Metadata?: {
|
||||
ratingKey: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface MetadataResponse {
|
||||
MediaContainer: {
|
||||
Metadata: {
|
||||
ratingKey: string;
|
||||
type: 'movie' | 'show';
|
||||
title: string;
|
||||
Guid: {
|
||||
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlexWatchlistItem {
|
||||
ratingKey: string;
|
||||
tmdbId: number;
|
||||
tvdbId?: number;
|
||||
type: 'movie' | 'show';
|
||||
title: string;
|
||||
}
|
||||
|
||||
class PlexTvAPI extends ExternalAPI {
|
||||
private authToken: string;
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor(authToken: string) {
|
||||
super(
|
||||
'https://plex.tv',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Token': authToken,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('plextv').data,
|
||||
}
|
||||
);
|
||||
|
||||
this.authToken = authToken;
|
||||
this.axios = axios.create({
|
||||
baseURL: 'https://plex.tv',
|
||||
headers: {
|
||||
'X-Plex-Token': this.authToken,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getDevices(): Promise<PlexDevice[]> {
|
||||
@@ -253,6 +287,83 @@ class PlexTvAPI {
|
||||
)) as UsersResponse;
|
||||
return parsedXml;
|
||||
}
|
||||
|
||||
public async getWatchlist({
|
||||
offset = 0,
|
||||
size = 20,
|
||||
}: { offset?: number; size?: number } = {}): Promise<{
|
||||
offset: number;
|
||||
size: number;
|
||||
totalSize: number;
|
||||
items: PlexWatchlistItem[];
|
||||
}> {
|
||||
try {
|
||||
const response = await this.axios.get<WatchlistResponse>(
|
||||
'/library/sections/watchlist/all',
|
||||
{
|
||||
params: {
|
||||
'X-Plex-Container-Start': offset,
|
||||
'X-Plex-Container-Size': size,
|
||||
},
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
|
||||
const watchlistDetails = await Promise.all(
|
||||
(response.data.MediaContainer.Metadata ?? []).map(
|
||||
async (watchlistItem) => {
|
||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
|
||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||
|
||||
const tmdbString = metadata.Guid.find((guid) =>
|
||||
guid.id.startsWith('tmdb')
|
||||
);
|
||||
const tvdbString = metadata.Guid.find((guid) =>
|
||||
guid.id.startsWith('tvdb')
|
||||
);
|
||||
|
||||
return {
|
||||
ratingKey: metadata.ratingKey,
|
||||
// This should always be set? But I guess it also cannot be?
|
||||
// We will filter out the 0's afterwards
|
||||
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
||||
tvdbId: tvdbString
|
||||
? Number(tvdbString.id.split('//')[1])
|
||||
: undefined,
|
||||
title: metadata.title,
|
||||
type: metadata.type,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||
|
||||
return {
|
||||
offset,
|
||||
size,
|
||||
totalSize: response.data.MediaContainer.totalSize,
|
||||
items: filteredList,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error('Failed to retrieve watchlist items', {
|
||||
label: 'Plex.TV Metadata API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return {
|
||||
offset,
|
||||
size,
|
||||
totalSize: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PlexTvAPI;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import cacheManager from '../lib/cache';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RTSearchResult {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AvailableCacheIds } from '../../lib/cache';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import type { DVRSettings } from '../../lib/settings';
|
||||
import ExternalAPI from '../externalapi';
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import type { DVRSettings } from '@server/lib/settings';
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logger from '../../logger';
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
export interface RadarrMovieOptions {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logger from '../../logger';
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
interface SonarrSeason {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { TautulliSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { uniqWith } from 'lodash';
|
||||
import type { User } from '../entity/User';
|
||||
import type { TautulliSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface TautulliHistoryRecord {
|
||||
date: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { sortBy } from 'lodash';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import ExternalAPI from '../externalapi';
|
||||
import type {
|
||||
TmdbCollection,
|
||||
TmdbExternalIdResponse,
|
||||
@@ -94,7 +94,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
nodeCache: cacheManager.getCache('tmdb').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 1,
|
||||
maxRPS: 50,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IssueType } from '@server/constants/issue';
|
||||
import { IssueStatus } from '@server/constants/issue';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -7,8 +9,6 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { IssueType } from '../constants/issue';
|
||||
import { IssueStatus } from '../constants/issue';
|
||||
import IssueComment from './IssueComment';
|
||||
import Media from './Media';
|
||||
import { User } from './User';
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
AfterLoad,
|
||||
Column,
|
||||
@@ -9,14 +17,6 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import type { DownloadingItem } from '../lib/downloadtracker';
|
||||
import downloadTracker from '../lib/downloadtracker';
|
||||
import { getRepository } from '../datasource';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import Season from './Season';
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import type {
|
||||
AddSeriesOptions,
|
||||
SonarrSeries,
|
||||
} from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isEqual, truncate } from 'lodash';
|
||||
import {
|
||||
AfterInsert,
|
||||
@@ -12,23 +32,341 @@ import {
|
||||
RelationCount,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { RadarrMovieOptions } from '../api/servarr/radarr';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import type { AddSeriesOptions, SonarrSeries } from '../api/servarr/sonarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
||||
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
||||
import { getRepository } from '../datasource';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Media from './Media';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
import { User } from './User';
|
||||
|
||||
export class RequestPermissionError extends Error {}
|
||||
export class QuotaRestrictedError extends Error {}
|
||||
export class DuplicateMediaRequestError extends Error {}
|
||||
export class NoSeasonsAvailableError extends Error {}
|
||||
|
||||
type MediaRequestOptions = {
|
||||
isAutoRequest?: boolean;
|
||||
};
|
||||
|
||||
@Entity()
|
||||
export class MediaRequest {
|
||||
public static async request(
|
||||
requestBody: MediaRequestBody,
|
||||
user: User,
|
||||
options: MediaRequestOptions = {}
|
||||
): Promise<MediaRequest> {
|
||||
const tmdb = new TheMovieDb();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
let requestUser = user;
|
||||
|
||||
if (
|
||||
requestBody.userId &&
|
||||
!requestUser.hasPermission([
|
||||
Permission.MANAGE_USERS,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
])
|
||||
) {
|
||||
throw new RequestPermissionError(
|
||||
'You do not have permission to modify the request user.'
|
||||
);
|
||||
} else if (requestBody.userId) {
|
||||
requestUser = await userRepository.findOneOrFail({
|
||||
where: { id: requestBody.userId },
|
||||
});
|
||||
}
|
||||
|
||||
if (!requestUser) {
|
||||
throw new Error('User missing from request context.');
|
||||
}
|
||||
|
||||
if (
|
||||
requestBody.mediaType === MediaType.MOVIE &&
|
||||
!requestUser.hasPermission(
|
||||
requestBody.is4k
|
||||
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
|
||||
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
)
|
||||
) {
|
||||
throw new RequestPermissionError(
|
||||
`You do not have permission to make ${
|
||||
requestBody.is4k ? '4K ' : ''
|
||||
}movie requests.`
|
||||
);
|
||||
} else if (
|
||||
requestBody.mediaType === MediaType.TV &&
|
||||
!requestUser.hasPermission(
|
||||
requestBody.is4k
|
||||
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
|
||||
: [Permission.REQUEST, Permission.REQUEST_TV],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
)
|
||||
) {
|
||||
throw new RequestPermissionError(
|
||||
`You do not have permission to make ${
|
||||
requestBody.is4k ? '4K ' : ''
|
||||
}series requests.`
|
||||
);
|
||||
}
|
||||
|
||||
const quotas = await requestUser.getQuota();
|
||||
|
||||
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
||||
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
||||
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
||||
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||
}
|
||||
|
||||
const tmdbMedia =
|
||||
requestBody.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: requestBody.mediaId,
|
||||
mediaType: requestBody.mediaType,
|
||||
},
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
||||
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
mediaType: requestBody.mediaType,
|
||||
});
|
||||
} else {
|
||||
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||
media.status = MediaStatus.PENDING;
|
||||
}
|
||||
|
||||
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
|
||||
media.status4k = MediaStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoin('request.media', 'media')
|
||||
.leftJoinAndSelect('request.requestedBy', 'user')
|
||||
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
||||
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
||||
.andWhere('media.mediaType = :mediaType', {
|
||||
mediaType: requestBody.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||
if (
|
||||
requestBody.mediaType === MediaType.MOVIE &&
|
||||
existing[0].status !== MediaRequestStatus.DECLINED
|
||||
) {
|
||||
logger.warn('Duplicate request for media blocked', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
mediaType: requestBody.mediaType,
|
||||
is4k: requestBody.is4k,
|
||||
label: 'Media Request',
|
||||
});
|
||||
|
||||
throw new DuplicateMediaRequestError(
|
||||
'Request for this media already exists.'
|
||||
);
|
||||
}
|
||||
|
||||
// If an existing auto-request for this media exists from the same user,
|
||||
// don't allow a new one.
|
||||
if (
|
||||
existing.find(
|
||||
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
|
||||
)
|
||||
) {
|
||||
throw new DuplicateMediaRequestError(
|
||||
'Auto-request for this media and user already exists.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestBody.mediaType === MediaType.MOVIE) {
|
||||
await mediaRepository.save(media);
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status: user.hasPermission(
|
||||
[
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: user.hasPermission(
|
||||
[
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? user
|
||||
: undefined,
|
||||
is4k: requestBody.is4k,
|
||||
serverId: requestBody.serverId,
|
||||
profileId: requestBody.profileId,
|
||||
rootFolder: requestBody.rootFolder,
|
||||
tags: requestBody.tags,
|
||||
isAutoRequest: options.isAutoRequest ?? false,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return request;
|
||||
} else {
|
||||
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||
ReturnType<typeof tmdb.getTvShow>
|
||||
>;
|
||||
const requestedSeasons =
|
||||
requestBody.seasons === 'all'
|
||||
? tmdbMediaShow.seasons
|
||||
.map((season) => season.season_number)
|
||||
.filter((sn) => sn > 0)
|
||||
: (requestBody.seasons as number[]);
|
||||
let existingSeasons: number[] = [];
|
||||
|
||||
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
||||
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
|
||||
// (Unless there are no seasons, in which case we abort)
|
||||
if (media.requests) {
|
||||
existingSeasons = media.requests
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === requestBody.is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.reduce((seasons, request) => {
|
||||
const combinedSeasons = request.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
return [...seasons, ...combinedSeasons];
|
||||
}, [] as number[]);
|
||||
}
|
||||
|
||||
// We should also check seasons that are available/partially available but don't have existing requests
|
||||
if (media.seasons) {
|
||||
existingSeasons = [
|
||||
...existingSeasons,
|
||||
...media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
)
|
||||
.map((season) => season.seasonNumber),
|
||||
];
|
||||
}
|
||||
|
||||
const finalSeasons = requestedSeasons.filter(
|
||||
(rs) => !existingSeasons.includes(rs)
|
||||
);
|
||||
|
||||
if (finalSeasons.length === 0) {
|
||||
throw new NoSeasonsAvailableError('No seasons available to request');
|
||||
} else if (
|
||||
quotas.tv.limit &&
|
||||
finalSeasons.length > (quotas.tv.remaining ?? 0)
|
||||
) {
|
||||
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status: user.hasPermission(
|
||||
[
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: user.hasPermission(
|
||||
[
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? user
|
||||
: undefined,
|
||||
is4k: requestBody.is4k,
|
||||
serverId: requestBody.serverId,
|
||||
profileId: requestBody.profileId,
|
||||
rootFolder: requestBody.rootFolder,
|
||||
languageProfileId: requestBody.languageProfileId,
|
||||
tags: requestBody.tags,
|
||||
seasons: finalSeasons.map(
|
||||
(sn) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber: sn,
|
||||
status: user.hasPermission(
|
||||
[
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
requestBody.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
})
|
||||
),
|
||||
isAutoRequest: options.isAutoRequest ?? false,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -119,6 +457,9 @@ export class MediaRequest {
|
||||
})
|
||||
public tags?: number[];
|
||||
|
||||
@Column({ default: false })
|
||||
public isAutoRequest: boolean;
|
||||
|
||||
constructor(init?: Partial<MediaRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -146,6 +487,10 @@ export class MediaRequest {
|
||||
}
|
||||
|
||||
this.sendNotification(media, Notification.MEDIA_PENDING);
|
||||
|
||||
if (this.isAutoRequest) {
|
||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +535,14 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED
|
||||
);
|
||||
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
autoApproved &&
|
||||
this.isAutoRequest
|
||||
) {
|
||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +1081,7 @@ export class MediaRequest {
|
||||
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||
let event: string | undefined;
|
||||
let notifyAdmin = true;
|
||||
let notifySystem = true;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_APPROVED:
|
||||
@@ -741,6 +1095,13 @@ export class MediaRequest {
|
||||
case Notification.MEDIA_PENDING:
|
||||
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
event = `${
|
||||
this.is4k ? '4K ' : ''
|
||||
}${mediaType} Request Automatically Submitted`;
|
||||
notifyAdmin = false;
|
||||
notifySystem = false;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
event = `${
|
||||
this.is4k ? '4K ' : ''
|
||||
@@ -757,6 +1118,7 @@ export class MediaRequest {
|
||||
media,
|
||||
request: this,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||
event,
|
||||
subject: `${movie.title}${
|
||||
@@ -775,6 +1137,7 @@ export class MediaRequest {
|
||||
media,
|
||||
request: this,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||
event,
|
||||
subject: `${tv.name}${
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { MediaStatus } from '../constants/media';
|
||||
import Media from './Media';
|
||||
|
||||
@Entity()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { MediaRequestStatus } from '../constants/media';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
|
||||
@Entity()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ISession } from 'connect-typeorm';
|
||||
import { Index, Column, PrimaryColumn, Entity } from 'typeorm';
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class Session implements ISession {
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { AfterDate } from '@server/utils/dateHelpers';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
@@ -14,16 +24,6 @@ import {
|
||||
RelationCount,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { UserType } from '../constants/user';
|
||||
import { getRepository } from '../datasource';
|
||||
import type { QuotaResponse } from '../interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '../lib/email';
|
||||
import type { PermissionCheckOptions } from '../lib/permissions';
|
||||
import { hasPermission, Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { AfterDate } from '../utils/dateHelpers';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces';
|
||||
import { hasNotificationType, Notification } from '@server/lib/notifications';
|
||||
import { NotificationAgentKey } from '@server/lib/settings';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
@@ -5,9 +8,6 @@ import {
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import type { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
|
||||
import { hasNotificationType, Notification } from '../lib/notifications';
|
||||
import { NotificationAgentKey } from '../lib/settings';
|
||||
import { User } from './User';
|
||||
|
||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||
@@ -57,6 +57,12 @@ export class UserSettings {
|
||||
@Column({ nullable: true })
|
||||
public telegramSendSilently?: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public watchlistSyncMovies?: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public watchlistSyncTv?: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import { Session } from '@server/entity/Session';
|
||||
import { User } from '@server/entity/User';
|
||||
import { startJobs } from '@server/job/schedule';
|
||||
import notificationManager from '@server/lib/notifications';
|
||||
import DiscordAgent from '@server/lib/notifications/agents/discord';
|
||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||
import TelegramAgent from '@server/lib/notifications/agents/telegram';
|
||||
import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
||||
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import routes from '@server/routes';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -11,27 +32,6 @@ import next from 'next';
|
||||
import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import YAML from 'yamljs';
|
||||
import PlexAPI from './api/plexapi';
|
||||
import dataSource, { getRepository } from './datasource';
|
||||
import { Session } from './entity/Session';
|
||||
import { User } from './entity/User';
|
||||
import { startJobs } from './job/schedule';
|
||||
import notificationManager from './lib/notifications';
|
||||
import DiscordAgent from './lib/notifications/agents/discord';
|
||||
import EmailAgent from './lib/notifications/agents/email';
|
||||
import GotifyAgent from './lib/notifications/agents/gotify';
|
||||
import LunaSeaAgent from './lib/notifications/agents/lunasea';
|
||||
import PushbulletAgent from './lib/notifications/agents/pushbullet';
|
||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
||||
import SlackAgent from './lib/notifications/agents/slack';
|
||||
import TelegramAgent from './lib/notifications/agents/telegram';
|
||||
import WebhookAgent from './lib/notifications/agents/webhook';
|
||||
import WebPushAgent from './lib/notifications/agents/webpush';
|
||||
import { getSettings } from './lib/settings';
|
||||
import logger from './logger';
|
||||
import routes from './routes';
|
||||
import { getAppVersion } from './utils/appVersion';
|
||||
import restartFlag from './utils/restartFlag';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
|
||||
@@ -3,3 +3,17 @@ export interface GenreSliderItem {
|
||||
name: string;
|
||||
backdrops: string[];
|
||||
}
|
||||
|
||||
export interface WatchlistItem {
|
||||
ratingKey: string;
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WatchlistResponse {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: WatchlistItem[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type Issue from '../../entity/Issue';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface IssueResultsResponse extends PaginatedResponse {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type Media from '../../entity/Media';
|
||||
import type { User } from '../../entity/User';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface MediaResultsResponse extends PaginatedResponse {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
||||
import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person';
|
||||
|
||||
export interface PersonCombinedCreditsResponse {
|
||||
id: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlexSettings } from '../../lib/settings';
|
||||
import type { PlexSettings } from '@server/lib/settings';
|
||||
|
||||
export interface PlexStatus {
|
||||
settings: PlexSettings;
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { MediaType } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { PaginatedResponse } from './common';
|
||||
import type { MediaRequest } from '../../entity/MediaRequest';
|
||||
|
||||
export interface RequestResultsResponse extends PaginatedResponse {
|
||||
results: MediaRequest[];
|
||||
}
|
||||
|
||||
export type MediaRequestBody = {
|
||||
mediaType: MediaType;
|
||||
mediaId: number;
|
||||
tvdbId?: number;
|
||||
seasons?: number[] | 'all';
|
||||
is4k?: boolean;
|
||||
serverId?: number;
|
||||
profileId?: number;
|
||||
rootFolder?: string;
|
||||
languageProfileId?: number;
|
||||
userId?: number;
|
||||
tags?: number[];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
|
||||
import type { LanguageProfile } from '../../api/servarr/sonarr';
|
||||
import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base';
|
||||
import type { LanguageProfile } from '@server/api/servarr/sonarr';
|
||||
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface PublicSettingsResponse {
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
hideRequested: boolean;
|
||||
localLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type Media from '../../entity/Media';
|
||||
import type { MediaRequest } from '../../entity/MediaRequest';
|
||||
import type { User } from '../../entity/User';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface UserResultsResponse extends PaginatedResponse {
|
||||
@@ -23,6 +23,7 @@ export interface QuotaResponse {
|
||||
movie: QuotaStatus;
|
||||
tv: QuotaStatus;
|
||||
}
|
||||
|
||||
export interface UserWatchDataResponse {
|
||||
recentlyWatched: Media[];
|
||||
playCount: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NotificationAgentKey } from '../../lib/settings';
|
||||
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||
|
||||
export interface UserSettingsGeneralResponse {
|
||||
username?: string;
|
||||
@@ -14,6 +14,8 @@ export interface UserSettingsGeneralResponse {
|
||||
globalMovieQuotaLimit?: number;
|
||||
globalTvQuotaLimit?: number;
|
||||
globalTvQuotaDays?: number;
|
||||
watchlistSyncMovies?: boolean;
|
||||
watchlistSyncTv?: boolean;
|
||||
}
|
||||
|
||||
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||
import type { JobId } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import watchlistSync from '@server/lib/watchlistsync';
|
||||
import logger from '@server/logger';
|
||||
import schedule from 'node-schedule';
|
||||
import downloadTracker from '../lib/downloadtracker';
|
||||
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
||||
import { radarrScanner } from '../lib/scanners/radarr';
|
||||
import { sonarrScanner } from '../lib/scanners/sonarr';
|
||||
import type { JobId } from '../lib/settings';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
interface ScheduledJob {
|
||||
id: JobId;
|
||||
@@ -54,6 +55,20 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => plexFullScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run watchlist sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
watchlistSync.syncWatchlist();
|
||||
}),
|
||||
});
|
||||
|
||||
// Run full radarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-scan',
|
||||
|
||||
@@ -6,7 +6,8 @@ export type AvailableCacheIds =
|
||||
| 'sonarr'
|
||||
| 'rt'
|
||||
| 'github'
|
||||
| 'plexguid';
|
||||
| 'plexguid'
|
||||
| 'plextv';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
@@ -58,6 +59,10 @@ class CacheManager {
|
||||
stdTtl: 86400 * 7, // 1 week cache
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
plextv: new Cache('plextv', 'Plex TV', {
|
||||
stdTtl: 86400 * 7, // 1 week cache
|
||||
checkPeriod: 60,
|
||||
}),
|
||||
};
|
||||
|
||||
public getCache(id: AvailableCacheIds): Cache {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { uniqWith } from 'lodash';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import { MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from './settings';
|
||||
|
||||
export interface DownloadingItem {
|
||||
mediaType: MediaType;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { NotificationAgentEmail } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import Email from 'email-templates';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { URL } from 'url';
|
||||
import type { NotificationAgentEmail } from '../settings';
|
||||
import { getSettings } from '../settings';
|
||||
import { openpgpEncrypt } from './openpgpEncrypt';
|
||||
|
||||
class PreparedEmail extends Email {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logger from '@server/logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import * as openpgp from 'openpgp';
|
||||
import type { TransformCallback } from 'stream';
|
||||
import { Transform } from 'stream';
|
||||
import logger from '../../logger';
|
||||
|
||||
interface EncryptorOptions {
|
||||
signingKey?: string;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type IssueComment from '@server/entity/IssueComment';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||
import type { Notification } from '..';
|
||||
import type Issue from '../../../entity/Issue';
|
||||
import type IssueComment from '../../../entity/IssueComment';
|
||||
import type Media from '../../../entity/Media';
|
||||
import type { MediaRequest } from '../../../entity/MediaRequest';
|
||||
import type { User } from '../../../entity/User';
|
||||
import type { NotificationAgentConfig } from '../../settings';
|
||||
|
||||
export interface NotificationPayload {
|
||||
event?: string;
|
||||
subject: string;
|
||||
notifySystem: boolean;
|
||||
notifyAdmin: boolean;
|
||||
notifyUser?: User;
|
||||
media?: Media;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentDiscord } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentDiscord } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -243,7 +243,10 @@ class DiscordAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { NotificationAgentEmail } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import type { EmailOptions } from 'email-templates';
|
||||
import path from 'path';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import PreparedEmail from '../../email';
|
||||
import type { NotificationAgentEmail } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -81,6 +81,11 @@ class EmailAgent
|
||||
is4k ? 'in 4K ' : ''
|
||||
}is pending approval:`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
body = `A new request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}was automatically submitted:`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import type { NotificationAgentGotify } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentGotify } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -117,7 +117,10 @@ class GotifyAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { IssueStatus, IssueType } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { NotificationAgentLunaSea } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentLunaSea } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -87,7 +87,10 @@ class LunaSeaAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushbullet } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentPushbullet } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -52,6 +53,12 @@ class PushbulletAgent
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
status =
|
||||
payload.media?.status === MediaStatus.PENDING
|
||||
? 'Pending Approval'
|
||||
: 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
@@ -104,6 +111,7 @@ class PushbulletAgent
|
||||
|
||||
// Send system notification
|
||||
if (
|
||||
payload.notifySystem &&
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushover } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentPushover } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -61,6 +62,12 @@ class PushoverAgent
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
status =
|
||||
payload.media?.status === MediaStatus.PENDING
|
||||
? 'Pending Approval'
|
||||
: 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
@@ -135,6 +142,7 @@ class PushoverAgent
|
||||
|
||||
// Send system notification
|
||||
if (
|
||||
payload.notifySystem &&
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import type { NotificationAgentSlack } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentSlack } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -225,7 +225,10 @@ class SlackAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentTelegram } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentTelegram } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -79,6 +80,12 @@ class TelegramAgent
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
status =
|
||||
payload.media?.status === MediaStatus.PENDING
|
||||
? 'Pending Approval'
|
||||
: 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
@@ -157,6 +164,7 @@ class TelegramAgent
|
||||
|
||||
// Send system notification
|
||||
if (
|
||||
payload.notifySystem &&
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.options.chatId
|
||||
) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IssueStatus, IssueType } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { NotificationAgentWebhook } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentWebhook } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -164,7 +164,10 @@ class WebhookAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import webpush from 'web-push';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
|
||||
import logger from '../../../logger';
|
||||
import type { NotificationAgentConfig } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
@@ -57,6 +57,11 @@ class WebPushAgent
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
message = payload.message;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
message = `Automatically submitted a new ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request.`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message = `Your ${
|
||||
is4k ? '4K ' : ''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { User } from '../../entity/User';
|
||||
import logger from '../../logger';
|
||||
import { Permission } from '../permissions';
|
||||
import type { User } from '@server/entity/User';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||
|
||||
export enum Notification {
|
||||
@@ -16,6 +16,7 @@ export enum Notification {
|
||||
ISSUE_COMMENT = 512,
|
||||
ISSUE_RESOLVED = 1024,
|
||||
ISSUE_REOPENED = 2048,
|
||||
MEDIA_AUTO_REQUESTED = 4096,
|
||||
}
|
||||
|
||||
export const hasNotificationType = (
|
||||
|
||||
@@ -21,7 +21,11 @@ export enum Permission {
|
||||
MANAGE_ISSUES = 1048576,
|
||||
VIEW_ISSUES = 2097152,
|
||||
CREATE_ISSUES = 4194304,
|
||||
AUTO_REQUEST = 8388608,
|
||||
AUTO_REQUEST_MOVIE = 16777216,
|
||||
AUTO_REQUEST_TV = 33554432,
|
||||
RECENT_VIEW = 67108864,
|
||||
WATCHLIST_VIEW = 134217728,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import Season from '@server/entity/Season';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import AsyncLock from '@server/utils/asyncLock';
|
||||
import { randomUUID } from 'crypto';
|
||||
import TheMovieDb from '../../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import { getRepository } from '../../datasource';
|
||||
import Media from '../../entity/Media';
|
||||
import Season from '../../entity/Season';
|
||||
import logger from '../../logger';
|
||||
import AsyncLock from '../../utils/asyncLock';
|
||||
import { getSettings } from '../settings';
|
||||
|
||||
// Default scan rates (can be overidden)
|
||||
const BUNDLE_SIZE = 20;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import animeList from '../../../api/animelist';
|
||||
import type { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
|
||||
import PlexAPI from '../../../api/plexapi';
|
||||
import type { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import cacheManager from '../../cache';
|
||||
import type { Library } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import animeList from '@server/api/animelist';
|
||||
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import type {
|
||||
MediaIds,
|
||||
ProcessableSeason,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '../baseScanner';
|
||||
import BaseScanner from '../baseScanner';
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import type { Library } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { RadarrMovie } from '@server/api/servarr/radarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import type {
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import type { RadarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { uniqWith } from 'lodash';
|
||||
import type { RadarrMovie } from '../../../api/servarr/radarr';
|
||||
import RadarrAPI from '../../../api/servarr/radarr';
|
||||
import type { RadarrSettings } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { RunnableScanner, StatusBase } from '../baseScanner';
|
||||
import BaseScanner from '../baseScanner';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: RadarrSettings;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import type { SonarrSeries } from '../../../api/servarr/sonarr';
|
||||
import SonarrAPI from '../../../api/servarr/sonarr';
|
||||
import type { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import Media from '../../../entity/Media';
|
||||
import type { SonarrSettings } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { SonarrSeries } from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import type {
|
||||
ProcessableSeason,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '../baseScanner';
|
||||
import BaseScanner from '../baseScanner';
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import type { SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: SonarrSettings;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
@@ -9,13 +9,17 @@ import type {
|
||||
TmdbSearchTvResponse,
|
||||
TmdbTvDetails,
|
||||
TmdbTvResult,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import {
|
||||
mapMovieDetailsToResult,
|
||||
mapPersonDetailsToResult,
|
||||
mapTvDetailsToResult,
|
||||
} from '../models/Search';
|
||||
import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers';
|
||||
} from '@server/models/Search';
|
||||
import {
|
||||
isMovie,
|
||||
isMovieDetails,
|
||||
isTvDetails,
|
||||
} from '@server/utils/typeHelpers';
|
||||
|
||||
interface SearchProvider {
|
||||
pattern: RegExp;
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface MainSettings {
|
||||
tv: Quota;
|
||||
};
|
||||
hideAvailable: boolean;
|
||||
hideRequested: boolean;
|
||||
localLogin: boolean;
|
||||
newPlexLogin: boolean;
|
||||
region: string;
|
||||
@@ -111,6 +112,7 @@ interface FullPublicSettings extends PublicSettings {
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
hideRequested: boolean;
|
||||
localLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
@@ -243,6 +245,7 @@ interface JobSettings {
|
||||
export type JobId =
|
||||
| 'plex-recently-added-scan'
|
||||
| 'plex-full-scan'
|
||||
| 'plex-watchlist-sync'
|
||||
| 'radarr-scan'
|
||||
| 'sonarr-scan'
|
||||
| 'download-sync'
|
||||
@@ -286,6 +289,7 @@ class Settings {
|
||||
tv: {},
|
||||
},
|
||||
hideAvailable: false,
|
||||
hideRequested: false,
|
||||
localLogin: true,
|
||||
newPlexLogin: true,
|
||||
region: '',
|
||||
@@ -398,6 +402,9 @@ class Settings {
|
||||
'plex-full-scan': {
|
||||
schedule: '0 0 3 * * *',
|
||||
},
|
||||
'plex-watchlist-sync': {
|
||||
schedule: '0 */10 * * * *',
|
||||
},
|
||||
'radarr-scan': {
|
||||
schedule: '0 0 4 * * *',
|
||||
},
|
||||
@@ -475,6 +482,7 @@ class Settings {
|
||||
applicationTitle: this.data.main.applicationTitle,
|
||||
applicationUrl: this.data.main.applicationUrl,
|
||||
hideAvailable: this.data.main.hideAvailable,
|
||||
hideRequested: this.data.main.hideRequested,
|
||||
localLogin: this.data.main.localLogin,
|
||||
movie4kEnabled: this.data.radarr.some(
|
||||
(radarr) => radarr.is4k && radarr.isDefault
|
||||
|
||||
163
server/lib/watchlistsync.ts
Normal file
163
server/lib/watchlistsync.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import {
|
||||
DuplicateMediaRequestError,
|
||||
MediaRequest,
|
||||
NoSeasonsAvailableError,
|
||||
QuotaRestrictedError,
|
||||
RequestPermissionError,
|
||||
} from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import logger from '@server/logger';
|
||||
import { Permission } from './permissions';
|
||||
|
||||
class WatchlistSync {
|
||||
public async syncWatchlist() {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
// Get users who actually have plex tokens
|
||||
const users = await userRepository
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.plexToken')
|
||||
.leftJoinAndSelect('user.settings', 'settings')
|
||||
.where("user.plexToken != ''")
|
||||
.getMany();
|
||||
|
||||
for (const user of users) {
|
||||
await this.syncUserWatchlist(user);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncUserWatchlist(user: User) {
|
||||
if (!user.plexToken) {
|
||||
logger.warn('Skipping user watchlist sync for user without plex token', {
|
||||
label: 'Plex Watchlist Sync',
|
||||
user: user.displayName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!user.hasPermission(
|
||||
[
|
||||
Permission.AUTO_REQUEST,
|
||||
Permission.AUTO_REQUEST_MOVIE,
|
||||
Permission.AUTO_APPROVE_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!user.settings?.watchlistSyncMovies &&
|
||||
!user.settings?.watchlistSyncTv
|
||||
) {
|
||||
// Skip sync if user settings have it disabled
|
||||
return;
|
||||
}
|
||||
|
||||
const plexTvApi = new PlexTvAPI(user.plexToken);
|
||||
|
||||
const response = await plexTvApi.getWatchlist({ size: 200 });
|
||||
|
||||
const mediaItems = await Media.getRelatedMedia(
|
||||
response.items.map((i) => i.tmdbId)
|
||||
);
|
||||
|
||||
const unavailableItems = response.items.filter(
|
||||
// If we can find watchlist items in our database that are also available, we should exclude them
|
||||
(i) =>
|
||||
!mediaItems.find(
|
||||
(m) =>
|
||||
m.tmdbId === i.tmdbId &&
|
||||
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
|
||||
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
unavailableItems.map(async (mediaItem) => {
|
||||
try {
|
||||
logger.info("Creating media request from user's Plex Watchlist", {
|
||||
label: 'Watchlist Sync',
|
||||
userId: user.id,
|
||||
mediaTitle: mediaItem.title,
|
||||
});
|
||||
|
||||
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
|
||||
throw new Error('Missing TVDB ID from Plex Metadata');
|
||||
}
|
||||
|
||||
// Check if they have auto-request permissons and watchlist sync
|
||||
// enabled for the media type
|
||||
if (
|
||||
((!user.hasPermission(
|
||||
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
|
||||
{ type: 'or' }
|
||||
) ||
|
||||
!user.settings?.watchlistSyncMovies) &&
|
||||
mediaItem.type === 'movie') ||
|
||||
((!user.hasPermission(
|
||||
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
|
||||
{ type: 'or' }
|
||||
) ||
|
||||
!user.settings?.watchlistSyncTv) &&
|
||||
mediaItem.type === 'show')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await MediaRequest.request(
|
||||
{
|
||||
mediaId: mediaItem.tmdbId,
|
||||
mediaType:
|
||||
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
|
||||
seasons: mediaItem.type === 'show' ? 'all' : undefined,
|
||||
tvdbId: mediaItem.tvdbId,
|
||||
is4k: false,
|
||||
},
|
||||
user,
|
||||
{ isAutoRequest: true }
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.constructor) {
|
||||
// During watchlist sync, these errors aren't necessarily
|
||||
// a problem with Overseerr. Since we are auto syncing these constantly, it's
|
||||
// possible they are unexpectedly at their quota limit, for example. So we'll
|
||||
// instead log these as debug messages.
|
||||
case RequestPermissionError:
|
||||
case DuplicateMediaRequestError:
|
||||
case QuotaRestrictedError:
|
||||
case NoSeasonsAvailableError:
|
||||
logger.debug('Failed to create media request from watchlist', {
|
||||
label: 'Watchlist Sync',
|
||||
userId: user.id,
|
||||
mediaTitle: mediaItem.title,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
logger.error('Failed to create media request from watchlist', {
|
||||
label: 'Watchlist Sync',
|
||||
userId: user.id,
|
||||
mediaTitle: mediaItem.title,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const watchlistSync = new WatchlistSync();
|
||||
|
||||
export default watchlistSync;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getRepository } from '../datasource';
|
||||
import { User } from '../entity/User';
|
||||
import type { Permission, PermissionCheckOptions } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
Permission,
|
||||
PermissionCheckOptions,
|
||||
} from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
|
||||
export const checkUser: Middleware = async (req, _res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWatchlistSyncUserSetting1660632269368
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddWatchlistSyncUserSetting1660632269368';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaRequestIsAutoRequestedField1660714479373
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddMediaRequestIsAutoRequestedField1660714479373';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TmdbCollection } from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import type Media from '@server/entity/Media';
|
||||
import { sortBy } from 'lodash';
|
||||
import type { TmdbCollection } from '../api/themoviedb/interfaces';
|
||||
import { MediaType } from '../constants/media';
|
||||
import type Media from '../entity/Media';
|
||||
import type { MovieResult } from './Search';
|
||||
import { mapMovieResult } from './Search';
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieReleaseResult,
|
||||
TmdbProductionCompany,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import type Media from '../entity/Media';
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type {
|
||||
Cast,
|
||||
Crew,
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
TmdbPersonCreditCast,
|
||||
TmdbPersonCreditCrew,
|
||||
TmdbPersonDetails,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import type Media from '../entity/Media';
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type Media from '@server/entity/Media';
|
||||
|
||||
export interface PersonDetails {
|
||||
id: number;
|
||||
|
||||
@@ -5,9 +5,9 @@ import type {
|
||||
TmdbPersonResult,
|
||||
TmdbTvDetails,
|
||||
TmdbTvResult,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import { MediaType as MainMediaType } from '../constants/media';
|
||||
import type Media from '../entity/Media';
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||
import type Media from '@server/entity/Media';
|
||||
|
||||
export type MediaType = 'tv' | 'movie' | 'person';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
TmdbTvEpisodeResult,
|
||||
TmdbTvRatingResult,
|
||||
TmdbTvSeasonResult,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import type Media from '../entity/Media';
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type {
|
||||
Cast,
|
||||
Crew,
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
TmdbVideoResult,
|
||||
TmdbWatchProviderDetails,
|
||||
TmdbWatchProviders,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import type { Video } from '../models/Movie';
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { Video } from '@server/models/Movie';
|
||||
|
||||
export interface ProductionCompany {
|
||||
id: number;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import PlexTvAPI from '../api/plextv';
|
||||
import { UserType } from '../constants/user';
|
||||
import { getRepository } from '../datasource';
|
||||
import { User } from '../entity/User';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const authRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import Media from '@server/entity/Media';
|
||||
import logger from '@server/logger';
|
||||
import { mapCollection } from '@server/models/Collection';
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import logger from '../logger';
|
||||
import { mapCollection } from '../models/Collection';
|
||||
|
||||
const collectionRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
GenreSliderItem,
|
||||
WatchlistResponse,
|
||||
} from '@server/interfaces/api/discoverInterfaces';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import {
|
||||
mapMovieResult,
|
||||
mapPersonResult,
|
||||
mapTvResult,
|
||||
} from '@server/models/Search';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import { sortBy } from 'lodash';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import type { User } from '../entity/User';
|
||||
import type { GenreSliderItem } from '../interfaces/api/discoverInterfaces';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { mapProductionCompany } from '../models/Movie';
|
||||
import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search';
|
||||
import { mapNetwork } from '../models/Tv';
|
||||
import { isMovie, isPerson } from '../utils/typeHelpers';
|
||||
|
||||
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
const settings = getSettings();
|
||||
@@ -704,4 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||
'/watchlist',
|
||||
async (req, res) => {
|
||||
const userRepository = getRepository(User);
|
||||
const itemsPerPage = 20;
|
||||
const page = req.params.page ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const activeUser = await userRepository.findOne({
|
||||
where: { id: req.user?.id },
|
||||
select: ['id', 'plexToken'],
|
||||
});
|
||||
|
||||
if (!activeUser?.plexToken) {
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
return res.json({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalResults: 0,
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const plexTV = new PlexTvAPI(activeUser.plexToken);
|
||||
|
||||
const watchlist = await plexTV.getWatchlist({ offset });
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
mediaType: item.type === 'show' ? 'tv' : 'movie',
|
||||
tmdbId: item.tmdbId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default discoverRoutes;
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import GithubAPI from '../api/github';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import GithubAPI from '@server/api/github';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbMovieResult,
|
||||
TmdbTvResult,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import type { StatusResponse } from '../interfaces/api/settingsInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { checkUser, isAuthenticated } from '../middleware/auth';
|
||||
import { mapProductionCompany } from '../models/Movie';
|
||||
import { mapNetwork } from '../models/Tv';
|
||||
import { appDataPath, appDataStatus } from '../utils/appDataVolume';
|
||||
import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
||||
import restartFlag from '../utils/restartFlag';
|
||||
import { isPerson } from '../utils/typeHelpers';
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import authRoutes from './auth';
|
||||
import collectionRoutes from './collection';
|
||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||
@@ -27,7 +28,6 @@ import personRoutes from './person';
|
||||
import requestRoutes from './request';
|
||||
import searchRoutes from './search';
|
||||
import serviceRoutes from './service';
|
||||
import settingsRoutes from './settings';
|
||||
import tvRoutes from './tv';
|
||||
import user from './user';
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { IssueStatus, IssueType } from '@server/constants/issue';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Issue from '@server/entity/Issue';
|
||||
import IssueComment from '@server/entity/IssueComment';
|
||||
import Media from '@server/entity/Media';
|
||||
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import { IssueStatus, IssueType } from '../constants/issue';
|
||||
import { getRepository } from '../datasource';
|
||||
import Issue from '../entity/Issue';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import Media from '../entity/Media';
|
||||
import type { IssueResultsResponse } from '../interfaces/api/issueInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const issueRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import IssueComment from '@server/entity/IssueComment';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from '../datasource';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const issueCommentRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import type { FindOneOptions } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import TautulliAPI from '../api/tautulli';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import { getRepository } from '../datasource';
|
||||
import Media from '../entity/Media';
|
||||
import { User } from '../entity/User';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
MediaResultsResponse,
|
||||
MediaWatchDataResponse,
|
||||
} from '../interfaces/api/mediaInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
} from '@server/interfaces/api/mediaInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import type { FindOneOptions } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
const mediaRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
import logger from '@server/logger';
|
||||
import { mapMovieDetails } from '@server/models/Movie';
|
||||
import { mapMovieResult } from '@server/models/Search';
|
||||
import { Router } from 'express';
|
||||
import RottenTomatoes from '../api/rottentomatoes';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import logger from '../logger';
|
||||
import { mapMovieDetails } from '../models/Movie';
|
||||
import { mapMovieResult } from '../models/Search';
|
||||
|
||||
const movieRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import logger from '../logger';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import Media from '@server/entity/Media';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
mapCastCredits,
|
||||
mapCrewCredits,
|
||||
mapPersonDetails,
|
||||
} from '../models/Person';
|
||||
} from '@server/models/Person';
|
||||
import { Router } from 'express';
|
||||
|
||||
const personRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import {
|
||||
DuplicateMediaRequestError,
|
||||
MediaRequest,
|
||||
NoSeasonsAvailableError,
|
||||
QuotaRestrictedError,
|
||||
RequestPermissionError,
|
||||
} from '@server/entity/MediaRequest';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
MediaRequestBody,
|
||||
RequestResultsResponse,
|
||||
} from '@server/interfaces/api/requestInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
||||
import { getRepository } from '../datasource';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import SeasonRequest from '../entity/SeasonRequest';
|
||||
import { User } from '../entity/User';
|
||||
import type { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const requestRoutes = Router();
|
||||
|
||||
@@ -146,302 +158,38 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
requestRoutes.post('/', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
try {
|
||||
let requestUser = req.user;
|
||||
|
||||
if (
|
||||
req.body.userId &&
|
||||
!req.user?.hasPermission([
|
||||
Permission.MANAGE_USERS,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
])
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to modify the request user.',
|
||||
});
|
||||
} else if (req.body.userId) {
|
||||
requestUser = await userRepository.findOneOrFail({
|
||||
where: { id: req.body.userId },
|
||||
});
|
||||
}
|
||||
|
||||
if (!requestUser) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User missing from request context.',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
req.body.mediaType === MediaType.MOVIE &&
|
||||
!req.user?.hasPermission(
|
||||
req.body.is4k
|
||||
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
|
||||
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: `You do not have permission to make ${
|
||||
req.body.is4k ? '4K ' : ''
|
||||
}movie requests.`,
|
||||
});
|
||||
} else if (
|
||||
req.body.mediaType === MediaType.TV &&
|
||||
!req.user?.hasPermission(
|
||||
req.body.is4k
|
||||
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
|
||||
: [Permission.REQUEST, Permission.REQUEST_TV],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: `You do not have permission to make ${
|
||||
req.body.is4k ? '4K ' : ''
|
||||
}series requests.`,
|
||||
});
|
||||
}
|
||||
|
||||
const quotas = await requestUser.getQuota();
|
||||
|
||||
if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Movie Quota Exceeded',
|
||||
});
|
||||
} else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Series Quota Exceeded',
|
||||
});
|
||||
}
|
||||
|
||||
const tmdbMedia =
|
||||
req.body.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: req.body.mediaId })
|
||||
: await tmdb.getTvShow({ tvId: req.body.mediaId });
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
||||
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
mediaType: req.body.mediaType,
|
||||
});
|
||||
} else {
|
||||
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
|
||||
media.status = MediaStatus.PENDING;
|
||||
}
|
||||
|
||||
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
|
||||
media.status4k = MediaStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.mediaType === MediaType.MOVIE) {
|
||||
const existing = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoin('request.media', 'media')
|
||||
.where('request.is4k = :is4k', { is4k: req.body.is4k })
|
||||
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
||||
.andWhere('media.mediaType = :mediaType', {
|
||||
mediaType: MediaType.MOVIE,
|
||||
})
|
||||
.andWhere('request.status != :requestStatus', {
|
||||
requestStatus: MediaRequestStatus.DECLINED,
|
||||
})
|
||||
.getOne();
|
||||
|
||||
if (existing) {
|
||||
logger.warn('Duplicate request for media blocked', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
mediaType: req.body.mediaType,
|
||||
is4k: req.body.is4k,
|
||||
label: 'Media Request',
|
||||
});
|
||||
requestRoutes.post<never, MediaRequest, MediaRequestBody>(
|
||||
'/',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Request for this media already exists.',
|
||||
status: 401,
|
||||
message: 'You must be logged in to request media.',
|
||||
});
|
||||
}
|
||||
const request = await MediaRequest.request(req.body, req.user);
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? req.user
|
||||
: undefined,
|
||||
is4k: req.body.is4k,
|
||||
serverId: req.body.serverId,
|
||||
profileId: req.body.profileId,
|
||||
rootFolder: req.body.rootFolder,
|
||||
tags: req.body.tags,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return res.status(201).json(request);
|
||||
} else if (req.body.mediaType === MediaType.TV) {
|
||||
const requestedSeasons = req.body.seasons as number[];
|
||||
let existingSeasons: number[] = [];
|
||||
|
||||
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
||||
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
|
||||
// (Unless there are no seasons, in which case we abort)
|
||||
if (media.requests) {
|
||||
existingSeasons = media.requests
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === req.body.is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.reduce((seasons, request) => {
|
||||
const combinedSeasons = request.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
return [...seasons, ...combinedSeasons];
|
||||
}, [] as number[]);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalSeasons = requestedSeasons.filter(
|
||||
(rs) => !existingSeasons.includes(rs)
|
||||
);
|
||||
|
||||
if (finalSeasons.length === 0) {
|
||||
return next({
|
||||
status: 202,
|
||||
message: 'No seasons available to request',
|
||||
});
|
||||
} else if (
|
||||
quotas.tv.limit &&
|
||||
finalSeasons.length > (quotas.tv.remaining ?? 0)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Series Quota Exceeded',
|
||||
});
|
||||
switch (error.constructor) {
|
||||
case RequestPermissionError:
|
||||
case QuotaRestrictedError:
|
||||
return next({ status: 403, message: error.message });
|
||||
case DuplicateMediaRequestError:
|
||||
return next({ status: 409, message: error.message });
|
||||
case NoSeasonsAvailableError:
|
||||
return next({ status: 202, message: error.message });
|
||||
default:
|
||||
return next({ status: 500, message: error.message });
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? req.user
|
||||
: undefined,
|
||||
is4k: req.body.is4k,
|
||||
serverId: req.body.serverId,
|
||||
profileId: req.body.profileId,
|
||||
rootFolder: req.body.rootFolder,
|
||||
languageProfileId: req.body.languageProfileId,
|
||||
tags: req.body.tags,
|
||||
seasons: finalSeasons.map(
|
||||
(sn) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber: sn,
|
||||
status: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return res.status(201).json(request);
|
||||
}
|
||||
|
||||
next({ status: 500, message: 'Invalid media type' });
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
requestRoutes.get('/count', async (_req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
|
||||
import Media from '@server/entity/Media';
|
||||
import { findSearchProvider } from '@server/lib/search';
|
||||
import logger from '@server/logger';
|
||||
import { mapSearchResults } from '@server/models/Search';
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import type { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces';
|
||||
import Media from '../entity/Media';
|
||||
import { findSearchProvider } from '../lib/search';
|
||||
import logger from '../logger';
|
||||
import { mapSearchResults } from '../models/Search';
|
||||
|
||||
const searchRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails,
|
||||
} from '../interfaces/api/serviceInterfaces';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
} from '@server/interfaces/api/serviceInterfaces';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const serviceRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { PlexConnection } from '@server/interfaces/api/plexInterfaces';
|
||||
import type {
|
||||
LogMessage,
|
||||
LogsResultsResponse,
|
||||
SettingsAboutResponse,
|
||||
} from '@server/interfaces/api/settingsInterfaces';
|
||||
import { scheduledJobs } from '@server/job/schedule';
|
||||
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { plexFullScanner } from '@server/lib/scanners/plex';
|
||||
import type { MainSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { appDataPath } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import fs from 'fs';
|
||||
@@ -6,30 +30,6 @@ import { rescheduleJob } from 'node-schedule';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import { URL } from 'url';
|
||||
import PlexAPI from '../../api/plexapi';
|
||||
import PlexTvAPI from '../../api/plextv';
|
||||
import TautulliAPI from '../../api/tautulli';
|
||||
import { getRepository } from '../../datasource';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import { User } from '../../entity/User';
|
||||
import type { PlexConnection } from '../../interfaces/api/plexInterfaces';
|
||||
import type {
|
||||
LogMessage,
|
||||
LogsResultsResponse,
|
||||
SettingsAboutResponse,
|
||||
} from '../../interfaces/api/settingsInterfaces';
|
||||
import { scheduledJobs } from '../../job/schedule';
|
||||
import type { AvailableCacheIds } from '../../lib/cache';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import { Permission } from '../../lib/permissions';
|
||||
import { plexFullScanner } from '../../lib/scanners/plex';
|
||||
import type { MainSettings } from '../../lib/settings';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
import { isAuthenticated } from '../../middleware/auth';
|
||||
import { appDataPath } from '../../utils/appDataVolume';
|
||||
import { getAppVersion } from '../../utils/appVersion';
|
||||
import notificationRoutes from './notifications';
|
||||
import radarrRoutes from './radarr';
|
||||
import sonarrRoutes from './sonarr';
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { User } from '@server/entity/User';
|
||||
import { Notification } from '@server/lib/notifications';
|
||||
import type { NotificationAgent } from '@server/lib/notifications/agents/agent';
|
||||
import DiscordAgent from '@server/lib/notifications/agents/discord';
|
||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||
import TelegramAgent from '@server/lib/notifications/agents/telegram';
|
||||
import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
||||
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { Router } from 'express';
|
||||
import type { User } from '../../entity/User';
|
||||
import { Notification } from '../../lib/notifications';
|
||||
import type { NotificationAgent } from '../../lib/notifications/agents/agent';
|
||||
import DiscordAgent from '../../lib/notifications/agents/discord';
|
||||
import EmailAgent from '../../lib/notifications/agents/email';
|
||||
import GotifyAgent from '../../lib/notifications/agents/gotify';
|
||||
import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
|
||||
import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
|
||||
import PushoverAgent from '../../lib/notifications/agents/pushover';
|
||||
import SlackAgent from '../../lib/notifications/agents/slack';
|
||||
import TelegramAgent from '../../lib/notifications/agents/telegram';
|
||||
import WebhookAgent from '../../lib/notifications/agents/webhook';
|
||||
import WebPushAgent from '../../lib/notifications/agents/webpush';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
|
||||
const notificationRoutes = Router();
|
||||
|
||||
const sendTestNotification = async (agent: NotificationAgent, user: User) =>
|
||||
await agent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifySystem: true,
|
||||
notifyAdmin: false,
|
||||
notifyUser: user,
|
||||
subject: 'Test Notification',
|
||||
@@ -247,7 +248,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
message: 'User information is missing from the request.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -363,7 +364,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
message: 'User information is missing from the request.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -384,34 +385,26 @@ notificationRoutes.get('/gotify', (_req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.gotify);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/gotify', (req, rest) => {
|
||||
notificationRoutes.post('/gotify', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.gotify = req.body;
|
||||
settings.save();
|
||||
|
||||
rest.status(200).json(settings.notifications.agents.gotify);
|
||||
res.status(200).json(settings.notifications.agents.gotify);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/gotify/test', async (req, rest, next) => {
|
||||
notificationRoutes.post('/gotify/test', async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information is missing from request',
|
||||
message: 'User information is missing from the request.',
|
||||
});
|
||||
}
|
||||
|
||||
const gotifyAgent = new GotifyAgent(req.body);
|
||||
if (
|
||||
await gotifyAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyAdmin: false,
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
return rest.status(204).send();
|
||||
if (await sendTestNotification(gotifyAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
status: 500,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import type { RadarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
import RadarrAPI from '../../api/servarr/radarr';
|
||||
import type { RadarrSettings } from '../../lib/settings';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const radarrRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import type { SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
import SonarrAPI from '../../api/servarr/sonarr';
|
||||
import type { SonarrSettings } from '../../lib/settings';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const sonarrRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
import logger from '@server/logger';
|
||||
import { mapTvResult } from '@server/models/Search';
|
||||
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
|
||||
import { Router } from 'express';
|
||||
import RottenTomatoes from '../api/rottentomatoes';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import logger from '../logger';
|
||||
import { mapTvResult } from '../models/Search';
|
||||
import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv';
|
||||
|
||||
const tvRoutes = Router();
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { findIndex, sortBy } from 'lodash';
|
||||
import { In } from 'typeorm';
|
||||
import PlexTvAPI from '../../api/plextv';
|
||||
import TautulliAPI from '../../api/tautulli';
|
||||
import { MediaType } from '../../constants/media';
|
||||
import { UserType } from '../../constants/user';
|
||||
import { getRepository } from '../../datasource';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import { User } from '../../entity/User';
|
||||
import { UserPushSubscription } from '../../entity/UserPushSubscription';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
QuotaResponse,
|
||||
UserRequestsResponse,
|
||||
UserResultsResponse,
|
||||
UserWatchDataResponse,
|
||||
} from '../../interfaces/api/userInterfaces';
|
||||
import { hasPermission, Permission } from '../../lib/permissions';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
import { isAuthenticated } from '../../middleware/auth';
|
||||
} from '@server/interfaces/api/userInterfaces';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { findIndex, sortBy } from 'lodash';
|
||||
import { In } from 'typeorm';
|
||||
import userSettingsRoutes from './usersettings';
|
||||
|
||||
const router = Router();
|
||||
@@ -606,4 +607,60 @@ router.get<{ id: string }, UserWatchDataResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
router.get<{ id: string; page?: number }, WatchlistResponse>(
|
||||
'/:id/watchlist',
|
||||
async (req, res, next) => {
|
||||
if (
|
||||
Number(req.params.id) !== req.user?.id &&
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message:
|
||||
"You do not have permission to view this user's Plex Watchlist.",
|
||||
});
|
||||
}
|
||||
|
||||
const itemsPerPage = 20;
|
||||
const page = req.params.page ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const user = await getRepository(User).findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
select: { id: true, plexToken: true },
|
||||
});
|
||||
|
||||
if (!user?.plexToken) {
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
return res.json({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalResults: 0,
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const plexTV = new PlexTvAPI(user.plexToken);
|
||||
|
||||
const watchlist = await plexTV.getWatchlist({ offset });
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
mediaType: item.type === 'show' ? 'tv' : 'movie',
|
||||
tmdbId: item.tmdbId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
import { getRepository } from '../../datasource';
|
||||
import { User } from '../../entity/User';
|
||||
import { UserSettings } from '../../entity/UserSettings';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserSettings } from '@server/entity/UserSettings';
|
||||
import type {
|
||||
UserSettingsGeneralResponse,
|
||||
UserSettingsNotificationsResponse,
|
||||
} from '../../interfaces/api/userSettingsInterfaces';
|
||||
import { Permission } from '../../lib/permissions';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
import { isAuthenticated } from '../../middleware/auth';
|
||||
} from '@server/interfaces/api/userSettingsInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
|
||||
const isOwnProfileOrAdmin = (): Middleware => {
|
||||
const authMiddleware: Middleware = (req, res, next) => {
|
||||
@@ -63,6 +63,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
|
||||
globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit,
|
||||
globalTvQuotaDays: defaultQuotas.tv.quotaDays,
|
||||
globalTvQuotaLimit: defaultQuotas.tv.quotaLimit,
|
||||
watchlistSyncMovies: user.settings?.watchlistSyncMovies,
|
||||
watchlistSyncTv: user.settings?.watchlistSyncTv,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
@@ -114,12 +116,16 @@ userSettingsRoutes.post<
|
||||
locale: req.body.locale,
|
||||
region: req.body.region,
|
||||
originalLanguage: req.body.originalLanguage,
|
||||
watchlistSyncMovies: req.body.watchlistSyncMovies,
|
||||
watchlistSyncTv: req.body.watchlistSyncTv,
|
||||
});
|
||||
} else {
|
||||
user.settings.discordId = req.body.discordId;
|
||||
user.settings.locale = req.body.locale;
|
||||
user.settings.region = req.body.region;
|
||||
user.settings.originalLanguage = req.body.originalLanguage;
|
||||
user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
|
||||
user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
|
||||
}
|
||||
|
||||
await userRepository.save(user);
|
||||
@@ -130,6 +136,8 @@ userSettingsRoutes.post<
|
||||
locale: user.settings.locale,
|
||||
region: user.settings.region,
|
||||
originalLanguage: user.settings.originalLanguage,
|
||||
watchlistSyncMovies: user.settings.watchlistSyncMovies,
|
||||
watchlistSyncTv: user.settings.watchlistSyncTv,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user