Skip to content
Open
175 changes: 175 additions & 0 deletions cypress/e2e/settings/discover-customization.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,179 @@ describe('Discover Customization', () => {
.first()
.should('not.contain', sliderTitle);
});

describe('Networks slider editor', () => {
const addNetwork = (id: string) => {
cy.get('[data-testid=discover-network-editor]')
.find('input')
.type(`${id}{enter}`);
};

beforeEach(() => {
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-network-editor]').should('be.visible');
});

it('shows a success toast when a network is added', () => {
cy.intercept('GET', '/api/v1/network/2697', {
body: { id: 2697, name: 'AcornTV', logoPath: '/acorntv.png' },
}).as('getNetwork');

addNetwork('2697');
cy.wait('@getNetwork');

cy.contains('AcornTV has been added to the list.').should('be.visible');
cy.get('[data-testid=discover-network-editor]')
.find('li')
.last()
.should('contain', 'AcornTV');
cy.get('[data-testid=discover-network-editor]')
.find('input')
.should('have.value', '');
});

it('does not add a network that is already in the list', () => {
cy.intercept('GET', '/api/v1/network/2697', {
body: { id: 2697, name: 'AcornTV', logoPath: '/acorntv.png' },
}).as('getNetwork');

addNetwork('2697');
cy.wait('@getNetwork');
cy.contains('AcornTV has been added to the list.').should('be.visible');

addNetwork('2697');

cy.contains('AcornTV is already in the list.').should('be.visible');
cy.get('[data-testid=discover-network-editor]')
.find('li')
.filter(':contains("AcornTV")')
.should('have.length', 1);
});

it('does not add a network without a logo', () => {
cy.intercept('GET', '/api/v1/network/9999', {
body: { id: 9999, name: 'No Logo TV' },
}).as('getNetwork');

addNetwork('9999');
cy.wait('@getNetwork');

cy.contains('No Logo TV has no logo, so it cannot be added.').should(
'be.visible'
);
cy.contains('has been added to the list.').should('not.exist');
});

it('shows an error when the network cannot be found', () => {
// The API responds with a 500 when TMDB cannot find the network
cy.intercept('GET', '/api/v1/network/8888', { statusCode: 500 }).as(
'getNetwork'
);

addNetwork('8888');
cy.wait('@getNetwork');

cy.contains('Could not find that network.').should('be.visible');
cy.contains('has been added to the list.').should('not.exist');
});
});

describe('Studios slider editor', () => {
const addStudio = (id: string) => {
cy.get('[data-testid=discover-studio-editor]')
.find('input')
.type(`${id}{enter}`);
};

beforeEach(() => {
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-studio-editor]').should('be.visible');
});

it('shows the default studios', () => {
cy.get('[data-testid=discover-studio-editor]')
.find('li')
.should('contain', 'Disney')
.and('contain', 'Pixar')
.and('contain', 'A24');
});

it('shows a success toast when a studio is added', () => {
cy.intercept('GET', '/api/v1/studio/1632', {
body: { id: 1632, name: 'Lionsgate', logoPath: '/lionsgate.png' },
}).as('getStudio');

addStudio('1632');
cy.wait('@getStudio');

cy.contains('Lionsgate has been added to the list.').should('be.visible');
cy.get('[data-testid=discover-studio-editor]')
.find('li')
.last()
.should('contain', 'Lionsgate');
cy.get('[data-testid=discover-studio-editor]')
.find('input')
.should('have.value', '');
});

it('does not add a studio that is already in the list', () => {
cy.intercept('GET', '/api/v1/studio/1632', {
body: { id: 1632, name: 'Lionsgate', logoPath: '/lionsgate.png' },
}).as('getStudio');

addStudio('1632');
cy.wait('@getStudio');
cy.contains('Lionsgate has been added to the list.').should('be.visible');

addStudio('1632');

cy.contains('Lionsgate is already in the list.').should('be.visible');
cy.get('[data-testid=discover-studio-editor]')
.find('li')
.filter(':contains("Lionsgate")')
.should('have.length', 1);
});

it('does not add a studio without a logo', () => {
cy.intercept('GET', '/api/v1/studio/9999', {
body: { id: 9999, name: 'No Logo Pictures' },
}).as('getStudio');

addStudio('9999');
cy.wait('@getStudio');

cy.contains(
'No Logo Pictures has no logo, so it cannot be added.'
).should('be.visible');
cy.contains('has been added to the list.').should('not.exist');
});

it('shows an error when the studio cannot be found', () => {
// The API responds with a 500 when TMDB cannot find the studio
cy.intercept('GET', '/api/v1/studio/8888', { statusCode: 500 }).as(
'getStudio'
);

addStudio('8888');
cy.wait('@getStudio');

cy.contains('Could not find that studio.').should('be.visible');
cy.contains('has been added to the list.').should('not.exist');
});

it('does not change the networks list', () => {
cy.intercept('GET', '/api/v1/studio/1632', {
body: { id: 1632, name: 'Lionsgate', logoPath: '/lionsgate.png' },
}).as('getStudio');

addStudio('1632');
cy.wait('@getStudio');

cy.get('[data-testid=discover-network-editor]')
.find('li')
.should('not.contain', 'Lionsgate');
});
});
});
30 changes: 30 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2107,6 +2107,10 @@ components:
type: boolean
data:
type: string
description: |
Extra data for the slider. For custom sliders this is the TMDB ID or search term the slider uses.
For the built-in Networks and Studios sliders it is a JSON encoded array of `{ id, name, logoPath }` objects
(at most 50), or `null` to use the default list of networks or studios.
example: '1234'
nullable: true
required:
Expand Down Expand Up @@ -3910,6 +3914,19 @@ paths:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
'400':
description: The request body or the Networks or Studios slider list is invalid
content:
application/json:
schema:
type: object
properties:
message:
type: string
description: |
`TOO_MANY_NETWORKS` or `INVALID_NETWORK_LIST` when the Networks slider list is rejected,
`TOO_MANY_STUDIOS` or `INVALID_STUDIO_LIST` when the Studios slider list is rejected.
example: TOO_MANY_NETWORKS
/settings/discover/{sliderId}:
put:
summary: Update a single slider
Expand Down Expand Up @@ -3938,6 +3955,7 @@ paths:
example: 1
data:
type: string
nullable: true
example: '1'
responses:
'200':
Expand All @@ -3946,6 +3964,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
'400':
description: The Networks or Studios slider list is invalid
content:
application/json:
schema:
type: object
properties:
message:
type: string
description: |
`TOO_MANY_NETWORKS`, `INVALID_NETWORK_LIST`, `TOO_MANY_STUDIOS` or `INVALID_STUDIO_LIST`.
example: INVALID_NETWORK_LIST
delete:
summary: Delete slider by ID
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
Expand Down
36 changes: 36 additions & 0 deletions server/constants/discover.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import { z } from 'zod';

export enum DiscoverSliderType {
RECENTLY_ADDED = 1,
Expand All @@ -24,6 +25,41 @@ export enum DiscoverSliderType {
TMDB_TV_STREAMING_SERVICES,
}

export const MAX_DISCOVER_NETWORKS = 50;
export const MAX_DISCOVER_STUDIOS = 50;
export const MAX_DISCOVER_BRAND_NAME_LENGTH = 200;

// A single TMDB image path such as `/wwemzKWzjKYJFfCeiB57q3r4Bcm.png`. It must
// start with a letter or digit, which rules out `/.` and `/..`.
export const DISCOVER_BRAND_LOGO_PATH = /^\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;

// The Networks and Studios sliders both show a list of brands (TMDB networks
// or production companies). This is shared by the server (when saving) and the
// client (when reading and adding brands) so both sides accept exactly the
// same list.
export const discoverBrandSchema = z.object({
id: z.number().int().positive(),
name: z.string().trim().min(1).max(MAX_DISCOVER_BRAND_NAME_LENGTH),
logoPath: z.string().regex(DISCOVER_BRAND_LOGO_PATH),
});

// Also drops brands with an id that is already in the list
export const discoverBrandsSchema = z
.array(discoverBrandSchema)
.transform((brands) => {
const seen = new Set<number>();

return brands.filter((brand) => {
if (seen.has(brand.id)) {
return false;
}

seen.add(brand.id);

return true;
});
});

export const defaultSliders: Partial<DiscoverSlider>[] = [
{
type: DiscoverSliderType.RECENTLY_ADDED,
Expand Down
4 changes: 4 additions & 0 deletions server/constants/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export enum ApiErrorCode {
ConnectionError = 'CONNECTION_ERROR',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
InvalidNetworkList = 'INVALID_NETWORK_LIST',
TooManyNetworks = 'TOO_MANY_NETWORKS',
InvalidStudioList = 'INVALID_STUDIO_LIST',
TooManyStudios = 'TOO_MANY_STUDIOS',
Unauthorized = 'UNAUTHORIZED',
Unknown = 'UNKNOWN',
}
4 changes: 2 additions & 2 deletions server/entity/DiscoverSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ class DiscoverSlider {
// use translations for them.
public title?: string;

@Column({ nullable: true })
public data?: string;
@Column({ type: 'text', nullable: true })
public data?: string | null;

@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
Expand Down
44 changes: 44 additions & 0 deletions server/lib/discoverBrands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { discoverBrandsSchema } from '@server/constants/discover';

interface SanitizeDiscoverBrandListOptions {
maxBrands: number;
// These let each slider keep its own error classes and error codes
createInvalidError: () => Error;
createTooManyError: () => Error;
}

// Validates the JSON list of brands (networks or studios) that is stored in
// the data of a built-in slider. `null` and an empty string mean "use the
// default list", while `[]` is a real, empty list.
export const sanitizeDiscoverBrandList = (
data: string | null,
{
maxBrands,
createInvalidError,
createTooManyError,
}: SanitizeDiscoverBrandListOptions
): string | null => {
if (data === null || data === '') {
return null;
}

let parsed: unknown;

try {
parsed = JSON.parse(data);
} catch {
throw createInvalidError();
}

const result = discoverBrandsSchema.safeParse(parsed);

if (!result.success) {
throw createInvalidError();
}

if (result.data.length > maxBrands) {
throw createTooManyError();
}

return JSON.stringify(result.data);
};
Loading