Skip to content

Commit

Permalink
feat: plex watchlist sync integration (#2885)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ryan Cohen committed Aug 21, 2022
1 parent 7943e0c commit 301f2bf
Show file tree
Hide file tree
Showing 35 changed files with 1,325 additions and 320 deletions.
37 changes: 37 additions & 0 deletions cypress/e2e/discover.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,41 @@ describe('Discover', () => {
.find('[data-testid=request-card-title]')
.contains('Movie Not Found');
});

it('loads plex watchlist', () => {
cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist' }).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', '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()
.click();
cy.get('[data-testid=media-title]').should('contain', text);
});
});
});
74 changes: 74 additions & 0 deletions cypress/e2e/user/auto-request-settings.cy.ts
Original file line number Diff line number Diff line change
@@ -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.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});

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();
});
});
25 changes: 25 additions & 0 deletions cypress/fixtures/watchlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"page": 1,
"totalPages": 1,
"totalResults": 20,
"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
}
]
}
40 changes: 40 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4403,6 +4403,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
Expand Down
135 changes: 123 additions & 12 deletions server/api/plextv.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import xml2js from 'xml2js';
import type { PlexDevice } from '../interfaces/api/plexInterfaces';
import cacheManager from '../lib/cache';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';

interface PlexAccountResponse {
user: PlexUser;
Expand Down Expand Up @@ -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[]> {
Expand Down Expand Up @@ -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;
2 changes: 1 addition & 1 deletion server/api/themoviedb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class TheMovieDb extends ExternalAPI {
nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 1,
maxRPS: 50,
},
}
);
Expand Down

0 comments on commit 301f2bf

Please sign in to comment.