diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts index 01487af87a..c48ce998d9 100644 --- a/cypress/e2e/settings/discover-customization.cy.ts +++ b/cypress/e2e/settings/discover-customization.cy.ts @@ -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'); + }); + }); }); diff --git a/seerr-api.yml b/seerr-api.yml index 24b825c79c..4672ba3cd7 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -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: @@ -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 @@ -3938,6 +3955,7 @@ paths: example: 1 data: type: string + nullable: true example: '1' responses: '200': @@ -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. diff --git a/server/constants/discover.ts b/server/constants/discover.ts index fda0682243..65390d4ac0 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -1,4 +1,5 @@ import type DiscoverSlider from '@server/entity/DiscoverSlider'; +import { z } from 'zod'; export enum DiscoverSliderType { RECENTLY_ADDED = 1, @@ -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(); + + return brands.filter((brand) => { + if (seen.has(brand.id)) { + return false; + } + + seen.add(brand.id); + + return true; + }); + }); + export const defaultSliders: Partial[] = [ { type: DiscoverSliderType.RECENTLY_ADDED, diff --git a/server/constants/error.ts b/server/constants/error.ts index 54640b206b..a7972f0c2f 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -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', } diff --git a/server/entity/DiscoverSlider.ts b/server/entity/DiscoverSlider.ts index c039b7d6c1..3267b76521 100644 --- a/server/entity/DiscoverSlider.ts +++ b/server/entity/DiscoverSlider.ts @@ -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; diff --git a/server/lib/discoverBrands.ts b/server/lib/discoverBrands.ts new file mode 100644 index 0000000000..06158d5876 --- /dev/null +++ b/server/lib/discoverBrands.ts @@ -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); +}; diff --git a/server/lib/discoverNetworks.test.ts b/server/lib/discoverNetworks.test.ts new file mode 100644 index 0000000000..c93ef4ed45 --- /dev/null +++ b/server/lib/discoverNetworks.test.ts @@ -0,0 +1,204 @@ +import { + discoverBrandsSchema, + MAX_DISCOVER_NETWORKS, +} from '@server/constants/discover'; +import { ApiErrorCode } from '@server/constants/error'; +import { + InvalidDiscoverNetworksError, + sanitizeDiscoverNetworks, + TooManyDiscoverNetworksError, +} from '@server/lib/discoverNetworks'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('sanitizeDiscoverNetworks', () => { + it('stores an empty value as the built-in default', () => { + assert.equal(sanitizeDiscoverNetworks(null), null); + assert.equal(sanitizeDiscoverNetworks(''), null); + }); + + it('keeps a valid network list and drops duplicates', () => { + const stored = sanitizeDiscoverNetworks( + JSON.stringify([ + { id: 2697, name: ' AcornTV ', logoPath: '/logo.png' }, + { id: 2697, name: 'AcornTV again', logoPath: '/other.png' }, + { id: 4025, name: 'BritBox', logoPath: '/brit.png' }, + ]) + ); + + assert.equal( + stored, + JSON.stringify([ + { id: 2697, name: 'AcornTV', logoPath: '/logo.png' }, + { id: 4025, name: 'BritBox', logoPath: '/brit.png' }, + ]) + ); + }); + + it('rejects a list of more than 50 networks', () => { + const networks = Array.from( + { length: MAX_DISCOVER_NETWORKS + 1 }, + (_, index) => ({ + id: index + 1, + name: `Network ${index + 1}`, + logoPath: '/logo.png', + }) + ); + + assert.throws( + () => sanitizeDiscoverNetworks(JSON.stringify(networks)), + (error: unknown) => { + assert.ok(error instanceof TooManyDiscoverNetworksError); + assert.equal(error.message, ApiErrorCode.TooManyNetworks); + return true; + } + ); + }); + + it('keeps an empty list as an empty list, not the default', () => { + assert.equal(sanitizeDiscoverNetworks('[]'), '[]'); + }); + + it('accepts a real TMDB logo path', () => { + const list = JSON.stringify([ + { + id: 213, + name: 'Netflix', + logoPath: '/wwemzKWzjKYJFfCeiB57q3r4Bcm.png', + }, + ]); + + assert.equal(sanitizeDiscoverNetworks(list), list); + }); + + it('rejects logo paths that are not a single safe file name', () => { + const invalidLogoPaths = [ + 'logo.png', + '/', + '/.', + '/..', + '/.hidden.png', + '/folder/logo.png', + '/logo.png?x=1', + `/${'a'.repeat(101)}`, + ]; + + for (const logoPath of invalidLogoPaths) { + assert.throws( + () => + sanitizeDiscoverNetworks( + JSON.stringify([{ id: 1, name: 'Netflix', logoPath }]) + ), + InvalidDiscoverNetworksError, + `expected ${logoPath} to be rejected` + ); + } + }); + + it('rejects networks with an invalid id or name', () => { + const invalidNetworks = [ + { id: 0, name: 'Netflix', logoPath: '/logo.png' }, + { id: 1.5, name: 'Netflix', logoPath: '/logo.png' }, + { id: 1, name: ' ', logoPath: '/logo.png' }, + { id: 1, name: 'a'.repeat(201), logoPath: '/logo.png' }, + ]; + + for (const network of invalidNetworks) { + assert.throws( + () => sanitizeDiscoverNetworks(JSON.stringify([network])), + InvalidDiscoverNetworksError + ); + } + }); + + it('uses the invalid list code for other errors', () => { + assert.throws( + () => sanitizeDiscoverNetworks('not-json'), + (error: unknown) => { + assert.ok(error instanceof InvalidDiscoverNetworksError); + assert.ok(!(error instanceof TooManyDiscoverNetworksError)); + assert.equal(error.message, ApiErrorCode.InvalidNetworkList); + return true; + } + ); + }); + + it('gives the client the same trimmed, deduplicated list as the server', () => { + const result = discoverBrandsSchema.safeParse([ + { id: 1, name: ' Netflix ', logoPath: '/logo.png' }, + { id: 1, name: 'Netflix again', logoPath: '/other.png' }, + ]); + + assert.ok(result.success); + assert.deepEqual(result.data, [ + { id: 1, name: 'Netflix', logoPath: '/logo.png' }, + ]); + }); + + it('rejects a list that is not network data', () => { + assert.throws( + () => sanitizeDiscoverNetworks('not-json'), + InvalidDiscoverNetworksError + ); + assert.throws( + () => + sanitizeDiscoverNetworks( + JSON.stringify([{ id: 1, name: 'Netflix', logoPath: 'https://evil' }]) + ), + InvalidDiscoverNetworksError + ); + }); + + it('counts the limit after deduplicating duplicate network ids', () => { + const networks = [ + ...Array.from({ length: 50 }, (_, index) => ({ + id: index + 1, + name: `Network ${index + 1}`, + logoPath: '/logo.png', + })), + { + id: 1, + name: 'Duplicate Network', + logoPath: '/duplicate.png', + }, + ]; + + const stored = sanitizeDiscoverNetworks(JSON.stringify(networks)); + + assert.equal( + stored, + JSON.stringify( + Array.from({ length: 50 }, (_, index) => ({ + id: index + 1, + name: `Network ${index + 1}`, + logoPath: '/logo.png', + })) + ) + ); + }); + + it('rejects more than 50 unique networks even if duplicates are present', () => { + const networks = [ + ...Array.from({ length: 50 }, (_, index) => ({ + id: index + 1, + name: `Network ${index + 1}`, + logoPath: '/logo.png', + })), + { + id: 51, + name: 'Network 51', + logoPath: '/logo51.png', + }, + { + id: 51, + name: 'Duplicate #51', + logoPath: '/logo51-dup.png', + }, + ]; + + assert.throws( + () => sanitizeDiscoverNetworks(JSON.stringify(networks)), + TooManyDiscoverNetworksError + ); + }); +}); diff --git a/server/lib/discoverNetworks.ts b/server/lib/discoverNetworks.ts new file mode 100644 index 0000000000..8bbd43c561 --- /dev/null +++ b/server/lib/discoverNetworks.ts @@ -0,0 +1,26 @@ +import { MAX_DISCOVER_NETWORKS } from '@server/constants/discover'; +import { ApiErrorCode } from '@server/constants/error'; +import { sanitizeDiscoverBrandList } from '@server/lib/discoverBrands'; + +// The message of these errors is an ApiErrorCode, which the client turns into +// a translated message. +export class InvalidDiscoverNetworksError extends Error { + constructor(message: string = ApiErrorCode.InvalidNetworkList) { + super(message); + this.name = 'InvalidDiscoverNetworksError'; + } +} + +export class TooManyDiscoverNetworksError extends InvalidDiscoverNetworksError { + constructor() { + super(ApiErrorCode.TooManyNetworks); + this.name = 'TooManyDiscoverNetworksError'; + } +} + +export const sanitizeDiscoverNetworks = (data: string | null): string | null => + sanitizeDiscoverBrandList(data, { + maxBrands: MAX_DISCOVER_NETWORKS, + createInvalidError: () => new InvalidDiscoverNetworksError(), + createTooManyError: () => new TooManyDiscoverNetworksError(), + }); diff --git a/server/lib/discoverStudios.test.ts b/server/lib/discoverStudios.test.ts new file mode 100644 index 0000000000..b080cab922 --- /dev/null +++ b/server/lib/discoverStudios.test.ts @@ -0,0 +1,101 @@ +import { MAX_DISCOVER_STUDIOS } from '@server/constants/discover'; +import { ApiErrorCode } from '@server/constants/error'; +import { + InvalidDiscoverStudiosError, + sanitizeDiscoverStudios, + TooManyDiscoverStudiosError, +} from '@server/lib/discoverStudios'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const studioList = (length: number) => + Array.from({ length }, (_, index) => ({ + id: index + 1, + name: `Studio ${index + 1}`, + logoPath: '/logo.png', + })); + +describe('sanitizeDiscoverStudios', () => { + it('stores an empty value as the built-in default', () => { + assert.equal(sanitizeDiscoverStudios(null), null); + assert.equal(sanitizeDiscoverStudios(''), null); + }); + + it('keeps a valid studio list and drops duplicates', () => { + const stored = sanitizeDiscoverStudios( + JSON.stringify([ + { id: 2, name: ' Disney ', logoPath: '/disney.png' }, + { id: 2, name: 'Disney again', logoPath: '/other.png' }, + { id: 3, name: 'Pixar', logoPath: '/pixar.png' }, + ]) + ); + + assert.equal( + stored, + JSON.stringify([ + { id: 2, name: 'Disney', logoPath: '/disney.png' }, + { id: 3, name: 'Pixar', logoPath: '/pixar.png' }, + ]) + ); + }); + + it('keeps an empty list as an empty list, not the default', () => { + assert.equal(sanitizeDiscoverStudios('[]'), '[]'); + }); + + it('rejects a list of more than 50 studios', () => { + assert.throws( + () => + sanitizeDiscoverStudios( + JSON.stringify(studioList(MAX_DISCOVER_STUDIOS + 1)) + ), + (error: unknown) => { + assert.ok(error instanceof TooManyDiscoverStudiosError); + assert.equal(error.message, ApiErrorCode.TooManyStudios); + return true; + } + ); + }); + + it('counts the limit after deduplicating duplicate studio ids', () => { + const studios = [ + ...studioList(MAX_DISCOVER_STUDIOS), + { id: 1, name: 'Duplicate Studio', logoPath: '/duplicate.png' }, + ]; + + assert.equal( + sanitizeDiscoverStudios(JSON.stringify(studios)), + JSON.stringify(studioList(MAX_DISCOVER_STUDIOS)) + ); + }); + + it('rejects studios with an invalid id, name or logo path', () => { + const invalidStudios = [ + { id: 0, name: 'Disney', logoPath: '/logo.png' }, + { id: 1.5, name: 'Disney', logoPath: '/logo.png' }, + { id: 1, name: ' ', logoPath: '/logo.png' }, + { id: 1, name: 'a'.repeat(201), logoPath: '/logo.png' }, + { id: 1, name: 'Disney', logoPath: 'https://evil' }, + { id: 1, name: 'Disney', logoPath: '/folder/logo.png' }, + ]; + + for (const studio of invalidStudios) { + assert.throws( + () => sanitizeDiscoverStudios(JSON.stringify([studio])), + InvalidDiscoverStudiosError + ); + } + }); + + it('uses the invalid studio list code for other errors', () => { + assert.throws( + () => sanitizeDiscoverStudios('not-json'), + (error: unknown) => { + assert.ok(error instanceof InvalidDiscoverStudiosError); + assert.ok(!(error instanceof TooManyDiscoverStudiosError)); + assert.equal(error.message, ApiErrorCode.InvalidStudioList); + return true; + } + ); + }); +}); diff --git a/server/lib/discoverStudios.ts b/server/lib/discoverStudios.ts new file mode 100644 index 0000000000..d38138b1af --- /dev/null +++ b/server/lib/discoverStudios.ts @@ -0,0 +1,26 @@ +import { MAX_DISCOVER_STUDIOS } from '@server/constants/discover'; +import { ApiErrorCode } from '@server/constants/error'; +import { sanitizeDiscoverBrandList } from '@server/lib/discoverBrands'; + +// The message of these errors is an ApiErrorCode, which the client turns into +// a translated message. +export class InvalidDiscoverStudiosError extends Error { + constructor(message: string = ApiErrorCode.InvalidStudioList) { + super(message); + this.name = 'InvalidDiscoverStudiosError'; + } +} + +export class TooManyDiscoverStudiosError extends InvalidDiscoverStudiosError { + constructor() { + super(ApiErrorCode.TooManyStudios); + this.name = 'TooManyDiscoverStudiosError'; + } +} + +export const sanitizeDiscoverStudios = (data: string | null): string | null => + sanitizeDiscoverBrandList(data, { + maxBrands: MAX_DISCOVER_STUDIOS, + createInvalidError: () => new InvalidDiscoverStudiosError(), + createTooManyError: () => new TooManyDiscoverStudiosError(), + }); diff --git a/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts b/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts new file mode 100644 index 0000000000..496445f916 --- /dev/null +++ b/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AlterDiscoverSliderDataToText1789300000000 implements MigrationInterface { + name = 'AlterDiscoverSliderDataToText1789300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "discover_slider" ALTER COLUMN "data" TYPE text` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "discover_slider" ALTER COLUMN "data" TYPE character varying` + ); + } +} diff --git a/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts b/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts new file mode 100644 index 0000000000..22698662bf --- /dev/null +++ b/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts @@ -0,0 +1,99 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AlterDiscoverSliderDataToText1789300000000 implements MigrationInterface { + name = 'AlterDiscoverSliderDataToText1789300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "discover_slider" RENAME TO "discover_slider_old"` + ); + + await queryRunner.query(` + CREATE TABLE "discover_slider" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "type" integer NOT NULL, + "order" integer NOT NULL, + "isBuiltIn" boolean NOT NULL DEFAULT (0), + "enabled" boolean NOT NULL DEFAULT (1), + "title" varchar, + "data" text, + "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ) + `); + + await queryRunner.query(` + INSERT INTO "discover_slider" ( + "id", + "type", + "order", + "isBuiltIn", + "enabled", + "title", + "data", + "createdAt", + "updatedAt" + ) + SELECT + "id", + "type", + "order", + "isBuiltIn", + "enabled", + "title", + "data", + "createdAt", + "updatedAt" + FROM "discover_slider_old" + `); + + await queryRunner.query(`DROP TABLE "discover_slider_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "discover_slider" RENAME TO "discover_slider_old"` + ); + + await queryRunner.query(` + CREATE TABLE "discover_slider" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "type" integer NOT NULL, + "order" integer NOT NULL, + "isBuiltIn" boolean NOT NULL DEFAULT (0), + "enabled" boolean NOT NULL DEFAULT (1), + "title" varchar, + "data" varchar, + "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ) + `); + + await queryRunner.query(` + INSERT INTO "discover_slider" ( + "id", + "type", + "order", + "isBuiltIn", + "enabled", + "title", + "data", + "createdAt", + "updatedAt" + ) + SELECT + "id", + "type", + "order", + "isBuiltIn", + "enabled", + "title", + "data", + "createdAt", + "updatedAt" + FROM "discover_slider_old" + `); + + await queryRunner.query(`DROP TABLE "discover_slider_old"`); + } +} diff --git a/server/routes/settings/discover.test.ts b/server/routes/settings/discover.test.ts new file mode 100644 index 0000000000..c5f57372a2 --- /dev/null +++ b/server/routes/settings/discover.test.ts @@ -0,0 +1,477 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + DiscoverSliderType, + MAX_DISCOVER_NETWORKS, + MAX_DISCOVER_STUDIOS, +} from '@server/constants/discover'; +import { ApiErrorCode } from '@server/constants/error'; +import { getRepository } from '@server/datasource'; +import DiscoverSlider from '@server/entity/DiscoverSlider'; +import { setupTestDb } from '@server/test/db'; +import type { Express } from 'express'; +import express from 'express'; +import request from 'supertest'; +import discoverSettingRoutes from './discover'; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use('/discover', discoverSettingRoutes); + return app; +} + +const app: Express = createApp(); + +setupTestDb(); + +const networkList = (length: number) => + Array.from({ length }, (_, index) => ({ + id: index + 1, + name: `Network ${index + 1}`, + logoPath: '/logo.png', + })); + +async function saveNetworksSlider() { + return getRepository(DiscoverSlider).save( + new DiscoverSlider({ + type: DiscoverSliderType.NETWORKS, + isBuiltIn: true, + enabled: true, + order: 0, + }) + ); +} + +describe('POST /discover network list', () => { + it('returns the too many networks code for more than 50 networks', async () => { + const slider = await saveNetworksSlider(); + + const response = await request(app) + .post('/discover') + .send([ + { + id: slider.id, + enabled: true, + data: JSON.stringify(networkList(MAX_DISCOVER_NETWORKS + 1)), + }, + ]); + + assert.equal(response.status, 400); + assert.equal(response.body.message, ApiErrorCode.TooManyNetworks); + + const stored = await getRepository(DiscoverSlider).findOneByOrFail({ + id: slider.id, + }); + assert.equal(stored.data ?? null, null); + }); + + it('returns the invalid list code for other invalid network lists', async () => { + const slider = await saveNetworksSlider(); + + const response = await request(app) + .post('/discover') + .send([ + { + id: slider.id, + enabled: true, + data: JSON.stringify([ + { id: 1, name: 'Netflix', logoPath: 'https://evil' }, + ]), + }, + ]); + + assert.equal(response.status, 400); + assert.equal(response.body.message, ApiErrorCode.InvalidNetworkList); + }); + + it('saves a custom list and clears it again with null', async () => { + const slider = await saveNetworksSlider(); + const repository = getRepository(DiscoverSlider); + const list = JSON.stringify(networkList(2)); + + const saved = await request(app) + .post('/discover') + .send([{ id: slider.id, enabled: true, data: list }]); + + assert.equal(saved.status, 200); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data, + list + ); + + const cleared = await request(app) + .put(`/discover/${slider.id}`) + .send({ data: null }); + + assert.equal(cleared.status, 200); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data ?? null, + null + ); + }); + + it('keeps an empty list and an empty value as different things', async () => { + const slider = await saveNetworksSlider(); + const repository = getRepository(DiscoverSlider); + + const emptyList = await request(app) + .post('/discover') + .send([{ id: slider.id, enabled: true, data: '[]' }]); + + assert.equal(emptyList.status, 200); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data, + '[]' + ); + + const emptyValue = await request(app) + .post('/discover') + .send([{ id: slider.id, enabled: true, data: '' }]); + + assert.equal(emptyValue.status, 200); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data ?? null, + null + ); + }); + + it('keeps the existing list when a later save is rejected', async () => { + const slider = await saveNetworksSlider(); + const repository = getRepository(DiscoverSlider); + const list = JSON.stringify(networkList(2)); + + await request(app) + .post('/discover') + .send([{ id: slider.id, enabled: true, data: list }]); + + const rejected = await request(app) + .post('/discover') + .send([ + { + id: slider.id, + enabled: true, + data: JSON.stringify(networkList(MAX_DISCOVER_NETWORKS + 1)), + }, + ]); + + assert.equal(rejected.status, 400); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data, + list + ); + + const rejectedPut = await request(app) + .put(`/discover/${slider.id}`) + .send({ data: 'not-json' }); + + assert.equal(rejectedPut.status, 400); + assert.equal(rejectedPut.body.message, ApiErrorCode.InvalidNetworkList); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data, + list + ); + }); + + it('returns the saved sliders from POST', async () => { + const slider = await saveNetworksSlider(); + + const response = await request(app) + .post('/discover') + .send([ + { + id: slider.id, + enabled: true, + data: JSON.stringify([ + { id: 1, name: ' Netflix ', logoPath: '/logo.png' }, + { id: 1, name: 'Netflix again', logoPath: '/other.png' }, + ]), + }, + ]); + + assert.equal(response.status, 200); + assert.equal(response.body.length, 1); + assert.equal(response.body[0].id, slider.id); + assert.equal( + response.body[0].data, + JSON.stringify([{ id: 1, name: 'Netflix', logoPath: '/logo.png' }]) + ); + }); + + it('ignores data for built-in sliders that are not Networks', async () => { + const repository = getRepository(DiscoverSlider); + const slider = await repository.save( + new DiscoverSlider({ + type: DiscoverSliderType.TRENDING, + isBuiltIn: true, + enabled: true, + order: 0, + }) + ); + + const posted = await request(app) + .post('/discover') + .send([ + { + id: slider.id, + enabled: false, + title: 'Changed', + type: DiscoverSliderType.NETWORKS, + data: JSON.stringify(networkList(2)), + }, + ]); + + assert.equal(posted.status, 200); + + const put = await request(app) + .put(`/discover/${slider.id}`) + .send({ data: JSON.stringify(networkList(2)) }); + + assert.equal(put.status, 200); + + const stored = await repository.findOneByOrFail({ id: slider.id }); + assert.equal(stored.type, DiscoverSliderType.TRENDING); + assert.equal(stored.enabled, false); + assert.equal(stored.data ?? null, null); + assert.equal(stored.title ?? null, null); + }); + + it('does not persist earlier sliders when a later networks payload is invalid', async () => { + const repository = getRepository(DiscoverSlider); + + const first = await repository.save( + new DiscoverSlider({ + type: DiscoverSliderType.NETWORKS, + isBuiltIn: true, + enabled: true, + order: 0, + data: JSON.stringify([ + { id: 1, name: 'Netflix', logoPath: '/logo.png' }, + ]), + }) + ); + + const second = await repository.save( + new DiscoverSlider({ + type: DiscoverSliderType.NETWORKS, + isBuiltIn: true, + enabled: true, + order: 1, + data: JSON.stringify([{ id: 2, name: 'HBO', logoPath: '/hbo.png' }]), + }) + ); + + const response = await request(app) + .post('/discover') + .send([ + { + id: first.id, + enabled: true, + data: JSON.stringify([ + { id: 1, name: 'Updated Netflix', logoPath: '/logo.png' }, + ]), + }, + { + id: second.id, + enabled: true, + data: JSON.stringify([ + { id: 1, name: 'Bad Network', logoPath: 'https://evil' }, + ]), + }, + ]); + + assert.equal(response.status, 400); + assert.equal(response.body.message, ApiErrorCode.InvalidNetworkList); + + const freshFirst = await repository.findOneByOrFail({ id: first.id }); + const freshSecond = await repository.findOneByOrFail({ id: second.id }); + + assert.equal( + freshFirst.data, + JSON.stringify([{ id: 1, name: 'Netflix', logoPath: '/logo.png' }]) + ); + assert.equal( + freshSecond.data, + JSON.stringify([{ id: 2, name: 'HBO', logoPath: '/hbo.png' }]) + ); + }); + + it('saves all valid sliders in a batch together', async () => { + const slider1 = await saveNetworksSlider(); + const slider2 = await saveNetworksSlider(); + + const response = await request(app) + .post('/discover') + .send([ + { id: slider1.id, enabled: true, data: JSON.stringify(networkList(2)) }, + { id: slider2.id, enabled: true, data: JSON.stringify(networkList(3)) }, + ]); + + assert.equal(response.status, 200); + assert.equal(response.body.length, 2); + + const repository = getRepository(DiscoverSlider); + assert.equal( + (await repository.findOneByOrFail({ id: slider1.id })).data, + JSON.stringify(networkList(2)) + ); + assert.equal( + (await repository.findOneByOrFail({ id: slider2.id })).data, + JSON.stringify(networkList(3)) + ); + }); +}); + +const studioList = (length: number) => + Array.from({ length }, (_, index) => ({ + id: index + 1, + name: `Studio ${index + 1}`, + logoPath: '/logo.png', + })); + +async function saveStudiosSlider() { + return getRepository(DiscoverSlider).save( + new DiscoverSlider({ + type: DiscoverSliderType.STUDIOS, + isBuiltIn: true, + enabled: true, + order: 0, + }) + ); +} + +describe('POST /discover studio list', () => { + it('returns the too many studios code for more than 50 studios', async () => { + const slider = await saveStudiosSlider(); + + const response = await request(app) + .post('/discover') + .send([ + { + id: slider.id, + enabled: true, + data: JSON.stringify(studioList(MAX_DISCOVER_STUDIOS + 1)), + }, + ]); + + assert.equal(response.status, 400); + assert.equal(response.body.message, ApiErrorCode.TooManyStudios); + + const stored = await getRepository(DiscoverSlider).findOneByOrFail({ + id: slider.id, + }); + assert.equal(stored.data ?? null, null); + }); + + it('returns the invalid studio list code for other invalid lists', async () => { + const slider = await saveStudiosSlider(); + + const post = await request(app) + .post('/discover') + .send([ + { + id: slider.id, + enabled: true, + data: JSON.stringify([ + { id: 1, name: 'Disney', logoPath: 'https://evil' }, + ]), + }, + ]); + + assert.equal(post.status, 400); + assert.equal(post.body.message, ApiErrorCode.InvalidStudioList); + + const put = await request(app) + .put(`/discover/${slider.id}`) + .send({ data: 'not-json' }); + + assert.equal(put.status, 400); + assert.equal(put.body.message, ApiErrorCode.InvalidStudioList); + }); + + it('saves a custom list and clears it again with null', async () => { + const slider = await saveStudiosSlider(); + const repository = getRepository(DiscoverSlider); + const list = JSON.stringify(studioList(2)); + + const saved = await request(app) + .post('/discover') + .send([{ id: slider.id, enabled: true, data: list }]); + + assert.equal(saved.status, 200); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data, + list + ); + + const cleared = await request(app) + .put(`/discover/${slider.id}`) + .send({ data: null }); + + assert.equal(cleared.status, 200); + assert.equal( + (await repository.findOneByOrFail({ id: slider.id })).data ?? null, + null + ); + }); + + it('keeps the networks and studios lists separate', async () => { + const repository = getRepository(DiscoverSlider); + const networks = await saveNetworksSlider(); + const studios = await saveStudiosSlider(); + + const response = await request(app) + .post('/discover') + .send([ + { + id: networks.id, + enabled: true, + data: JSON.stringify(networkList(2)), + }, + { + id: studios.id, + enabled: true, + data: JSON.stringify(studioList(3)), + }, + ]); + + assert.equal(response.status, 200); + assert.equal( + (await repository.findOneByOrFail({ id: networks.id })).data, + JSON.stringify(networkList(2)) + ); + assert.equal( + (await repository.findOneByOrFail({ id: studios.id })).data, + JSON.stringify(studioList(3)) + ); + }); + + it('does not save earlier sliders when a later studio list is invalid', async () => { + const repository = getRepository(DiscoverSlider); + const networks = await saveNetworksSlider(); + const studios = await saveStudiosSlider(); + + const response = await request(app) + .post('/discover') + .send([ + { + id: networks.id, + enabled: true, + data: JSON.stringify(networkList(2)), + }, + { + id: studios.id, + enabled: true, + data: JSON.stringify(studioList(MAX_DISCOVER_STUDIOS + 1)), + }, + ]); + + assert.equal(response.status, 400); + assert.equal(response.body.message, ApiErrorCode.TooManyStudios); + assert.equal( + (await repository.findOneByOrFail({ id: networks.id })).data ?? null, + null + ); + }); +}); diff --git a/server/routes/settings/discover.ts b/server/routes/settings/discover.ts index 344ce72bea..9865c3c735 100644 --- a/server/routes/settings/discover.ts +++ b/server/routes/settings/discover.ts @@ -1,8 +1,52 @@ +import { DiscoverSliderType } from '@server/constants/discover'; import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; +import { + InvalidDiscoverNetworksError, + sanitizeDiscoverNetworks, +} from '@server/lib/discoverNetworks'; +import { + InvalidDiscoverStudiosError, + sanitizeDiscoverStudios, +} from '@server/lib/discoverStudios'; import logger from '@server/logger'; import { Router } from 'express'; +// The built-in sliders that show a list the admin can change. Their data is a +// JSON list of brands that has to be validated before it is saved. +const brandListSanitizers: Partial< + Record string | null> +> = { + [DiscoverSliderType.NETWORKS]: sanitizeDiscoverNetworks, + [DiscoverSliderType.STUDIOS]: sanitizeDiscoverStudios, +}; + +// Returns the error code when the list is rejected, otherwise null +const applyBrandList = ( + existingSlider: DiscoverSlider, + data: string | null | undefined +): string | null => { + const sanitize = brandListSanitizers[existingSlider.type]; + + if (!sanitize || data === undefined) { + return null; + } + + try { + existingSlider.data = sanitize(data); + return null; + } catch (e) { + if ( + e instanceof InvalidDiscoverNetworksError || + e instanceof InvalidDiscoverStudiosError + ) { + return e.message; + } + + throw e; + } +}; + const discoverSettingRoutes = Router(); discoverSettingRoutes.post('/', async (req, res) => { @@ -14,6 +58,9 @@ discoverSettingRoutes.post('/', async (req, res) => { return res.status(400).json({ message: 'Invalid request body.' }); } + // Prepare all sliders and validate before saving any + const prepared: DiscoverSlider[] = []; + for (let x = 0; x < sliders.length; x++) { const slider = sliders[x]; const existingSlider = await sliderRepository.findOne({ @@ -26,14 +73,21 @@ discoverSettingRoutes.post('/', async (req, res) => { existingSlider.enabled = slider.enabled; existingSlider.order = x; - // Only allow changes to the following when the slider is not built in + // Built-in sliders are fixed, except Networks and Studios, whose data is + // the shared list. if (!existingSlider.isBuiltIn) { existingSlider.title = slider.title; existingSlider.data = slider.data; existingSlider.type = slider.type; + } else { + const brandListError = applyBrandList(existingSlider, slider.data); + + if (brandListError) { + return res.status(400).json({ message: brandListError }); + } } - await sliderRepository.save(existingSlider); + prepared.push(existingSlider); } else { const newSlider = new DiscoverSlider({ isBuiltIn: false, @@ -43,11 +97,14 @@ discoverSettingRoutes.post('/', async (req, res) => { order: x, type: slider.type, }); - await sliderRepository.save(newSlider); + prepared.push(newSlider); } } - return res.json(sliders); + // All validation passed; now save everything together + const savedSliders = await sliderRepository.save(prepared); + + return res.json(savedSliders); }); discoverSettingRoutes.post('/add', async (req, res) => { @@ -89,11 +146,18 @@ discoverSettingRoutes.put('/:sliderId', async (req, res, next) => { }, }); - // Only allow changes to the following when the slider is not built in + // Built-in sliders are fixed, except Networks and Studios, whose data is + // the shared list. if (!existingSlider.isBuiltIn) { existingSlider.title = slider.title; existingSlider.data = slider.data; existingSlider.type = slider.type; + } else { + const brandListError = applyBrandList(existingSlider, slider.data); + + if (brandListError) { + return res.status(400).json({ message: brandListError }); + } } await sliderRepository.save(existingSlider); diff --git a/src/components/CompanyCard/index.tsx b/src/components/BrandCard/index.tsx similarity index 91% rename from src/components/CompanyCard/index.tsx rename to src/components/BrandCard/index.tsx index 84c11bf42b..0c57b2b605 100644 --- a/src/components/CompanyCard/index.tsx +++ b/src/components/BrandCard/index.tsx @@ -2,13 +2,13 @@ import CachedImage from '@app/components/Common/CachedImage'; import Link from 'next/link'; import { useState } from 'react'; -interface CompanyCardProps { +interface BrandCardProps { name: string; image: string; url: string; } -const CompanyCard = ({ image, url, name }: CompanyCardProps) => { +const BrandCard = ({ image, url, name }: BrandCardProps) => { const [isHovered, setHovered] = useState(false); return ( @@ -50,4 +50,4 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => { ); }; -export default CompanyCard; +export default BrandCard; diff --git a/src/components/Discover/BrandListEditor/index.tsx b/src/components/Discover/BrandListEditor/index.tsx new file mode 100644 index 0000000000..a6fcf281a9 --- /dev/null +++ b/src/components/Discover/BrandListEditor/index.tsx @@ -0,0 +1,281 @@ +import Button from '@app/components/Common/Button'; +import { + isBrandLogoPath, + isDefaultBrandList, + type DiscoverBrand, +} from '@app/components/Discover/brandLists'; +import useToasts from '@app/hooks/useToasts'; +import { + ChevronDownIcon, + ChevronUpIcon, + XMarkIcon, +} from '@heroicons/react/24/solid'; +import { + MAX_DISCOVER_BRAND_NAME_LENGTH, + discoverBrandSchema, +} from '@server/constants/discover'; +import axios from 'axios'; +import { useEffect, useRef, useState } from 'react'; +import type { MessageDescriptor } from 'react-intl'; +import { useIntl } from 'react-intl'; + +// The labels of one list. Messages that mention a brand get its name as +// `{name}`, and `tooMany` gets the limit as `{max}`. +export interface BrandListEditorMessages { + title: MessageDescriptor; + idPlaceholder: MessageDescriptor; + add: MessageDescriptor; + resetToDefault: MessageDescriptor; + remove: MessageDescriptor; + moveUp: MessageDescriptor; + moveDown: MessageDescriptor; + invalidId: MessageDescriptor; + lookupFailed: MessageDescriptor; + missingLogo: MessageDescriptor; + duplicate: MessageDescriptor; + added: MessageDescriptor; + tooMany: MessageDescriptor; +} + +interface BrandListEditorProps { + brands: DiscoverBrand[]; + defaultBrands: DiscoverBrand[]; + maxBrands: number; + // The API that returns a single brand, such as `/api/v1/network` + lookupUrl: string; + testId: string; + messages: BrandListEditorMessages; + onChange: (brands: DiscoverBrand[]) => void; +} + +interface LookedUpBrand { + id: number; + name: string; + logoPath?: string; +} + +const BrandListEditor = ({ + brands, + defaultBrands, + maxBrands, + lookupUrl, + testId, + messages, + onChange, +}: BrandListEditorProps) => { + const intl = useIntl(); + const { addToast } = useToasts(); + const [brandId, setBrandId] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const brandsRef = useRef(brands); + const isMounted = useRef(true); + + useEffect(() => { + brandsRef.current = brands; + }, [brands]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + const showError = (message: string) => + addToast(message, { + appearance: 'error', + autoDismiss: true, + }); + + const canAddBrand = (list: DiscoverBrand[], id: number): boolean => { + const existing = list.find((brand) => brand.id === id); + + if (existing) { + showError( + intl.formatMessage(messages.duplicate, { name: existing.name }) + ); + return false; + } + + if (list.length >= maxBrands) { + showError(intl.formatMessage(messages.tooMany, { max: maxBrands })); + return false; + } + + return true; + }; + + const moveBrand = (index: number, direction: -1 | 1) => { + const nextIndex = index + direction; + + if (nextIndex < 0 || nextIndex >= brands.length) { + return; + } + + const reordered = brands.slice(); + const [moved] = reordered.splice(index, 1); + + if (!moved) { + return; + } + + reordered.splice(nextIndex, 0, moved); + onChange(reordered); + }; + + const addBrand = async () => { + const id = Number(brandId.trim()); + + if (!Number.isInteger(id) || id <= 0) { + showError(intl.formatMessage(messages.invalidId)); + return; + } + + if (!canAddBrand(brands, id)) { + return; + } + + setIsAdding(true); + + try { + const response = await axios.get(`${lookupUrl}/${id}`); + const brand = response.data; + + // The editor may have closed, or the list may have changed, while the + // lookup was running, so check again against the latest list. + if (!isMounted.current) { + return; + } + + const currentBrands = brandsRef.current; + + if (!canAddBrand(currentBrands, brand.id)) { + return; + } + + if (!brand.logoPath || !isBrandLogoPath(brand.logoPath)) { + showError( + intl.formatMessage(messages.missingLogo, { name: brand.name }) + ); + return; + } + + // Use the same rules as the server so a brand that is added here can + // always be saved. A very long name is shortened instead of rejected. + const validated = discoverBrandSchema.safeParse({ + id: brand.id, + name: brand.name?.trim().slice(0, MAX_DISCOVER_BRAND_NAME_LENGTH), + logoPath: brand.logoPath, + }); + + if (!validated.success) { + showError(intl.formatMessage(messages.lookupFailed)); + return; + } + + onChange([...currentBrands, validated.data]); + setBrandId(''); + addToast( + intl.formatMessage(messages.added, { name: validated.data.name }), + { + appearance: 'success', + autoDismiss: true, + } + ); + } catch { + if (isMounted.current) { + showError(intl.formatMessage(messages.lookupFailed)); + } + } finally { + if (isMounted.current) { + setIsAdding(false); + } + } + }; + + return ( +
+
+ + {intl.formatMessage(messages.title)} + + +
+
    + {brands.map((brand, index) => ( +
  • + {brand.name} + + + +
  • + ))} +
+
{ + event.preventDefault(); + void addBrand(); + }} + > + setBrandId(event.target.value)} + /> + +
+
+ ); +}; + +export default BrandListEditor; diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index c24a0591ad..985b8a4191 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -125,6 +125,13 @@ const DiscoverSliderEdit = ({ }, }); + // The Networks and Studios sliders keep a list in their data, which is + // edited inside the slider instead of being shown as a tag + const hasTagData = + !!slider.data && + slider.type !== DiscoverSliderType.NETWORKS && + slider.type !== DiscoverSliderType.STUDIOS; + const getSliderTitle = (slider: Partial): string => { switch (slider.type) { case DiscoverSliderType.RECENTLY_ADDED: @@ -197,7 +204,7 @@ const DiscoverSliderEdit = ({ )}
@@ -206,7 +213,7 @@ const DiscoverSliderEdit = ({
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD || diff --git a/src/components/Discover/NetworkSlider/Editor.tsx b/src/components/Discover/NetworkSlider/Editor.tsx new file mode 100644 index 0000000000..60f7c2f8c1 --- /dev/null +++ b/src/components/Discover/NetworkSlider/Editor.tsx @@ -0,0 +1,59 @@ +import BrandListEditor from '@app/components/Discover/BrandListEditor'; +import { + defaultDiscoverNetworks, + type DiscoverNetwork, +} from '@app/components/Discover/networks'; +import defineMessages from '@app/utils/defineMessages'; +import { MAX_DISCOVER_NETWORKS } from '@server/constants/discover'; + +const messages = defineMessages('components.Discover.NetworkSlider.Editor', { + editNetworks: 'Displayed networks', + networkIdPlaceholder: 'TMDB Network ID', + addNetwork: 'Add', + resetToDefaultNetworks: 'Reset to Default Networks', + removeNetwork: 'Remove {name}', + moveUp: 'Move {name} up', + moveDown: 'Move {name} down', + invalidNetworkId: 'Enter a TMDB Network ID.', + networkLookupFailed: 'Could not find that network.', + networkMissingLogo: '{name} has no logo, so it cannot be added.', + duplicateNetwork: '{name} is already in the list.', + networkAdded: '{name} has been added to the list.', + tooManyNetworks: 'A network list can contain at most {max} networks.', +}); + +interface NetworkSliderEditorProps { + networks: DiscoverNetwork[]; + onChange: (networks: DiscoverNetwork[]) => void; +} + +const NetworkSliderEditor = ({ + networks, + onChange, +}: NetworkSliderEditorProps) => ( + +); + +export default NetworkSliderEditor; diff --git a/src/components/Discover/NetworkSlider/index.tsx b/src/components/Discover/NetworkSlider/index.tsx index f19cce2c18..b748cf07b4 100644 --- a/src/components/Discover/NetworkSlider/index.tsx +++ b/src/components/Discover/NetworkSlider/index.tsx @@ -1,158 +1,43 @@ -import CompanyCard from '@app/components/CompanyCard'; +import BrandCard from '@app/components/BrandCard'; +import NetworkSliderEditor from '@app/components/Discover/NetworkSlider/Editor'; +import { brandLogoUrl } from '@app/components/Discover/brandLists'; +import { + parseDiscoverNetworks, + serializeDiscoverNetworks, +} from '@app/components/Discover/networks'; import Slider from '@app/components/Slider'; import defineMessages from '@app/utils/defineMessages'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Discover.NetworkSlider', { networks: 'Networks', + emptyNetworks: 'No networks selected.', }); -interface Network { - name: string; - image: string; - url: string; +interface NetworkSliderProps { + data?: string | null; + isEditing?: boolean; + onChange?: (data: string | null) => void; } -const networks: Network[] = [ - { - name: 'Netflix', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png', - url: '/discover/tv/network/213', - }, - { - name: 'Disney+', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png', - url: '/discover/tv/network/2739', - }, - { - name: 'Prime Video', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png', - url: '/discover/tv/network/1024', - }, - { - name: 'Apple TV+', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/4KAy34EHvRM25Ih8wb82AuGU7zJ.png', - url: '/discover/tv/network/2552', - }, - { - name: 'Hulu', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pqUTCleNUiTLAVlelGxUgWn1ELh.png', - url: '/discover/tv/network/453', - }, - { - name: 'HBO', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png', - url: '/discover/tv/network/49', - }, - { - name: 'Discovery+', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1D1bS3Dyw4ScYnFWTlBOvJXC3nb.png', - url: '/discover/tv/network/4353', - }, - { - name: 'ABC', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png', - url: '/discover/tv/network/2', - }, - { - name: 'FOX', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png', - url: '/discover/tv/network/19', - }, - { - name: 'Cinemax', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png', - url: '/discover/tv/network/359', - }, - { - name: 'AMC', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pmvRmATOCaDykE6JrVoeYxlFHw3.png', - url: '/discover/tv/network/174', - }, - { - name: 'Showtime', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png', - url: '/discover/tv/network/67', - }, - { - name: 'Starz', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png', - url: '/discover/tv/network/318', - }, - { - name: 'The CW', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png', - url: '/discover/tv/network/71', - }, - { - name: 'NBC', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png', - url: '/discover/tv/network/6', - }, - { - name: 'CBS', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png', - url: '/discover/tv/network/16', - }, - { - name: 'Paramount+', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fi83B1oztoS47xxcemFdPMhIzK.png', - url: '/discover/tv/network/4330', - }, - { - name: 'BBC One', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png', - url: '/discover/tv/network/4', - }, - { - name: 'Cartoon Network', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png', - url: '/discover/tv/network/56', - }, - { - name: 'Adult Swim', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png', - url: '/discover/tv/network/80', - }, - { - name: 'Nickelodeon', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png', - url: '/discover/tv/network/13', - }, - { - name: 'Peacock', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gIAcGTjKKr0KOHL5s4O36roJ8p7.png', - url: '/discover/tv/network/3353', - }, -]; - -const NetworkSlider = () => { +const NetworkSlider = ({ + data, + isEditing = false, + onChange, +}: NetworkSliderProps) => { const intl = useIntl(); + const networks = parseDiscoverNetworks(data); return ( <> + {isEditing && onChange && ( + + onChange(serializeDiscoverNetworks(nextNetworks)) + } + /> + )}
{intl.formatMessage(messages.networks)} @@ -161,16 +46,16 @@ const NetworkSlider = () => { ( - ( + ))} - emptyMessage="" + emptyMessage={intl.formatMessage(messages.emptyNetworks)} /> ); diff --git a/src/components/Discover/StudioSlider/Editor.tsx b/src/components/Discover/StudioSlider/Editor.tsx new file mode 100644 index 0000000000..908e5a9802 --- /dev/null +++ b/src/components/Discover/StudioSlider/Editor.tsx @@ -0,0 +1,56 @@ +import BrandListEditor from '@app/components/Discover/BrandListEditor'; +import { + defaultDiscoverStudios, + type DiscoverStudio, +} from '@app/components/Discover/studios'; +import defineMessages from '@app/utils/defineMessages'; +import { MAX_DISCOVER_STUDIOS } from '@server/constants/discover'; + +const messages = defineMessages('components.Discover.StudioSlider.Editor', { + editStudios: 'Displayed studios', + studioIdPlaceholder: 'TMDB Studio ID', + addStudio: 'Add', + resetToDefaultStudios: 'Reset to Default Studios', + removeStudio: 'Remove {name}', + moveUp: 'Move {name} up', + moveDown: 'Move {name} down', + invalidStudioId: 'Enter a TMDB Studio ID.', + studioLookupFailed: 'Could not find that studio.', + studioMissingLogo: '{name} has no logo, so it cannot be added.', + duplicateStudio: '{name} is already in the list.', + studioAdded: '{name} has been added to the list.', + tooManyStudios: 'A studio list can contain at most {max} studios.', +}); + +interface StudioSliderEditorProps { + studios: DiscoverStudio[]; + onChange: (studios: DiscoverStudio[]) => void; +} + +const StudioSliderEditor = ({ studios, onChange }: StudioSliderEditorProps) => ( + +); + +export default StudioSliderEditor; diff --git a/src/components/Discover/StudioSlider/index.tsx b/src/components/Discover/StudioSlider/index.tsx index 6b23f4bdd9..fd8c121221 100644 --- a/src/components/Discover/StudioSlider/index.tsx +++ b/src/components/Discover/StudioSlider/index.tsx @@ -1,92 +1,43 @@ -import CompanyCard from '@app/components/CompanyCard'; +import BrandCard from '@app/components/BrandCard'; +import StudioSliderEditor from '@app/components/Discover/StudioSlider/Editor'; +import { brandLogoUrl } from '@app/components/Discover/brandLists'; +import { + parseDiscoverStudios, + serializeDiscoverStudios, +} from '@app/components/Discover/studios'; import Slider from '@app/components/Slider'; import defineMessages from '@app/utils/defineMessages'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Discover.StudioSlider', { studios: 'Studios', + emptyStudios: 'No studios selected.', }); -interface Studio { - name: string; - image: string; - url: string; +interface StudioSliderProps { + data?: string | null; + isEditing?: boolean; + onChange?: (data: string | null) => void; } -const studios: Studio[] = [ - { - name: 'Disney', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wdrCwmRnLFJhEoH8GSfymY85KHT.png', - url: '/discover/movies/studio/2', - }, - { - name: '20th Century Studios', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/h0rjX5vjW5r8yEnUBStFarjcLT4.png', - url: '/discover/movies/studio/127928', - }, - { - name: 'Sony Pictures', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/GagSvqWlyPdkFHMfQ3pNq6ix9P.png', - url: '/discover/movies/studio/34', - }, - { - name: 'Warner Bros. Pictures', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png', - url: '/discover/movies/studio/174', - }, - { - name: 'Universal', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png', - url: '/discover/movies/studio/33', - }, - { - name: 'Paramount', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fycMZt242LVjagMByZOLUGbCvv3.png', - url: '/discover/movies/studio/4', - }, - { - name: 'Pixar', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png', - url: '/discover/movies/studio/3', - }, - { - name: 'Dreamworks', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png', - url: '/discover/movies/studio/521', - }, - { - name: 'Marvel Studios', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png', - url: '/discover/movies/studio/420', - }, - { - name: 'DC', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png', - url: '/discover/movies/studio/9993', - }, - { - name: 'A24', - image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png', - url: '/discover/movies/studio/41077', - }, -]; - -const StudioSlider = () => { +const StudioSlider = ({ + data, + isEditing = false, + onChange, +}: StudioSliderProps) => { const intl = useIntl(); + const studios = parseDiscoverStudios(data); return ( <> + {isEditing && onChange && ( + + onChange(serializeDiscoverStudios(nextStudios)) + } + /> + )}
{intl.formatMessage(messages.studios)} @@ -95,16 +46,16 @@ const StudioSlider = () => { ( - ( + ))} - emptyMessage="" + emptyMessage={intl.formatMessage(messages.emptyStudios)} /> ); diff --git a/src/components/Discover/brandLists.ts b/src/components/Discover/brandLists.ts new file mode 100644 index 0000000000..4140000ec1 --- /dev/null +++ b/src/components/Discover/brandLists.ts @@ -0,0 +1,60 @@ +import { + DISCOVER_BRAND_LOGO_PATH, + discoverBrandsSchema, +} from '@server/constants/discover'; + +// The Networks and Studios sliders both show a list of brands (TMDB networks +// or production companies). +export interface DiscoverBrand { + id: number; + name: string; + logoPath: string; +} + +export const isBrandLogoPath = (logoPath: string): boolean => + DISCOVER_BRAND_LOGO_PATH.test(logoPath); + +export const brandLogoUrl = (logoPath: string): string => + `https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${logoPath}`; + +export const parseBrandList = ( + data: string | null | undefined, + defaults: DiscoverBrand[] +): DiscoverBrand[] => { + if (!data) { + return defaults; + } + + try { + // The same schema the server uses, so names are trimmed and duplicate ids + // are dropped here too + const result = discoverBrandsSchema.safeParse(JSON.parse(data)); + + return result.success ? result.data : defaults; + } catch { + return defaults; + } +}; + +export const isDefaultBrandList = ( + brands: DiscoverBrand[], + defaults: DiscoverBrand[] +): boolean => + brands.length === defaults.length && + brands.every((brand, index) => { + const fallback = defaults[index]; + + return ( + brand.id === fallback.id && + brand.name === fallback.name && + brand.logoPath === fallback.logoPath + ); + }); + +// The default list is stored as null, so changes to the defaults in a later +// version reach everyone who never customized the list. +export const serializeBrandList = ( + brands: DiscoverBrand[], + defaults: DiscoverBrand[] +): string | null => + isDefaultBrandList(brands, defaults) ? null : JSON.stringify(brands); diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 2a6313bafb..ec3df5d4cb 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -27,7 +27,12 @@ import { PencilIcon, PlusIcon, } from '@heroicons/react/24/solid'; -import { DiscoverSliderType } from '@server/constants/discover'; +import { + DiscoverSliderType, + MAX_DISCOVER_NETWORKS, + MAX_DISCOVER_STUDIOS, +} from '@server/constants/discover'; +import { ApiErrorCode } from '@server/constants/error'; import type DiscoverSlider from '@server/entity/DiscoverSlider'; import axios from 'axios'; import { useEffect, useState } from 'react'; @@ -47,11 +52,53 @@ const messages = defineMessages('components.Discover', { resetsuccess: 'Sucessfully reset discover customization settings.', resetfailed: 'Something went wrong resetting the discover customization settings.', + toomanynetworks: 'A network list can contain at most {maxNetworks} networks.', + invalidnetworklist: 'The list of networks is not valid.', + toomanystudios: 'A studio list can contain at most {maxStudios} studios.', + invalidstudiolist: 'The list of studios is not valid.', customizediscover: 'Customize Discover', stopediting: 'Stop Editing', createnewslider: 'Create New Slider', }); +// After a slider is created, edited or deleted on the server, merge the fresh +// list into the local one so unsaved changes (order, enabled state and the +// networks and studios lists) are not thrown away. +const mergeRefreshedSliders = ( + current: Partial[], + refreshed: DiscoverSlider[] +): Partial[] => { + const refreshedById = new Map( + refreshed.map((slider) => [slider.id, slider]) + ); + const currentIds = new Set(current.map((slider) => slider.id)); + + // New sliders are created with order -1, so the server lists them first + const added = refreshed.filter((slider) => !currentIds.has(slider.id)); + + const kept = current.flatMap((slider) => { + const refreshedSlider = refreshedById.get(slider.id); + + if (!refreshedSlider) { + return []; + } + + // Built-in sliders cannot be edited elsewhere, so the local copy is newer + return [ + refreshedSlider.isBuiltIn + ? slider + : { + ...slider, + title: refreshedSlider.title, + type: refreshedSlider.type, + data: refreshedSlider.data, + }, + ]; + }); + + return [...added, ...kept]; +}; + const Discover = () => { const intl = useIntl(); const { hasPermission } = useUser(); @@ -74,6 +121,29 @@ const Discover = () => { const hasChanged = () => !Object.is(discoverData, sliders); + // Used by the Networks and Studios sliders to change their list locally + const updateSliderData = ( + sliderId: number | undefined, + data: string | null + ) => + setSliders((currentSliders) => + currentSliders.map((currentSlider) => + currentSlider.id === sliderId + ? { ...currentSlider, data } + : currentSlider + ) + ); + + const refreshSliders = async () => { + const refreshed = await mutate(); + + if (refreshed) { + setSliders((currentSliders) => + mergeRefreshedSliders(currentSliders, refreshed) + ); + } + }; + const updateSliders = async () => { try { await axios.post('/api/v1/settings/discover', sliders); @@ -84,8 +154,31 @@ const Discover = () => { }); setIsEditing(false); mutate(); - } catch { - addToast(intl.formatMessage(messages.updatefailed), { + } catch (error) { + // A 400 carries an error code when the networks or studios list was + // rejected + const errorCode = + axios.isAxiosError(error) && error.response?.status === 400 + ? error.response.data?.message + : undefined; + + let toastMessage = intl.formatMessage(messages.updatefailed); + + if (errorCode === ApiErrorCode.TooManyNetworks) { + toastMessage = intl.formatMessage(messages.toomanynetworks, { + maxNetworks: MAX_DISCOVER_NETWORKS, + }); + } else if (errorCode === ApiErrorCode.InvalidNetworkList) { + toastMessage = intl.formatMessage(messages.invalidnetworklist); + } else if (errorCode === ApiErrorCode.TooManyStudios) { + toastMessage = intl.formatMessage(messages.toomanystudios, { + maxStudios: MAX_DISCOVER_STUDIOS, + }); + } else if (errorCode === ApiErrorCode.InvalidStudioList) { + toastMessage = intl.formatMessage(messages.invalidstudiolist); + } + + addToast(toastMessage, { appearance: 'error', autoDismiss: true, }); @@ -134,15 +227,7 @@ const Discover = () => {
- { - const newSliders = await mutate(); - - if (newSliders) { - setSliders(newSliders); - } - }} - /> +
)} @@ -256,7 +341,13 @@ const Discover = () => { ); break; case DiscoverSliderType.STUDIOS: - sliderComponent = ; + sliderComponent = ( + updateSliderData(slider.id, data)} + /> + ); break; case DiscoverSliderType.POPULAR_TV: sliderComponent = ( @@ -283,7 +374,13 @@ const Discover = () => { ); break; case DiscoverSliderType.NETWORKS: - sliderComponent = ; + sliderComponent = ( + updateSliderData(slider.id, data)} + /> + ); break; case DiscoverSliderType.TMDB_MOVIE_KEYWORD: sliderComponent = ( @@ -405,17 +502,17 @@ const Discover = () => { { - const newSliders = await mutate(); - - if (newSliders) { - setSliders(newSliders); - } - }} + onDelete={refreshSliders} onEnable={() => { - const tempSliders = sliders.slice(); - tempSliders[index].enabled = !tempSliders[index].enabled; - setSliders(tempSliders); + // Copy the slider instead of changing it, since the original + // object is shared with the SWR cache + setSliders( + sliders.map((currentSlider, sliderIndex) => + sliderIndex === index + ? { ...currentSlider, enabled: !currentSlider.enabled } + : currentSlider + ) + ); }} onPositionUpdate={(updatedItemId, position, hasClickedArrows) => { const originalPosition = sliders.findIndex( diff --git a/src/components/Discover/networks.ts b/src/components/Discover/networks.ts new file mode 100644 index 0000000000..ff7d01f4dd --- /dev/null +++ b/src/components/Discover/networks.ts @@ -0,0 +1,61 @@ +import { + isDefaultBrandList, + parseBrandList, + serializeBrandList, + type DiscoverBrand, +} from '@app/components/Discover/brandLists'; + +export type DiscoverNetwork = DiscoverBrand; + +export const defaultDiscoverNetworks: DiscoverNetwork[] = [ + { id: 213, name: 'Netflix', logoPath: '/wwemzKWzjKYJFfCeiB57q3r4Bcm.png' }, + { id: 2739, name: 'Disney+', logoPath: '/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png' }, + { + id: 1024, + name: 'Prime Video', + logoPath: '/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png', + }, + { id: 2552, name: 'Apple TV+', logoPath: '/4KAy34EHvRM25Ih8wb82AuGU7zJ.png' }, + { id: 453, name: 'Hulu', logoPath: '/pqUTCleNUiTLAVlelGxUgWn1ELh.png' }, + { id: 49, name: 'HBO', logoPath: '/tuomPhY2UtuPTqqFnKMVHvSb724.png' }, + { + id: 4353, + name: 'Discovery+', + logoPath: '/1D1bS3Dyw4ScYnFWTlBOvJXC3nb.png', + }, + { id: 2, name: 'ABC', logoPath: '/ndAvF4JLsliGreX87jAc9GdjmJY.png' }, + { id: 19, name: 'FOX', logoPath: '/1DSpHrWyOORkL9N2QHX7Adt31mQ.png' }, + { id: 359, name: 'Cinemax', logoPath: '/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png' }, + { id: 174, name: 'AMC', logoPath: '/pmvRmATOCaDykE6JrVoeYxlFHw3.png' }, + { id: 67, name: 'Showtime', logoPath: '/Allse9kbjiP6ExaQrnSpIhkurEi.png' }, + { id: 318, name: 'Starz', logoPath: '/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png' }, + { id: 71, name: 'The CW', logoPath: '/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png' }, + { id: 6, name: 'NBC', logoPath: '/o3OedEP0f9mfZr33jz2BfXOUK5.png' }, + { id: 16, name: 'CBS', logoPath: '/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png' }, + { + id: 4330, + name: 'Paramount+', + logoPath: '/fi83B1oztoS47xxcemFdPMhIzK.png', + }, + { id: 4, name: 'BBC One', logoPath: '/mVn7xESaTNmjBUyUtGNvDQd3CT1.png' }, + { + id: 56, + name: 'Cartoon Network', + logoPath: '/c5OC6oVCg6QP4eqzW6XIq17CQjI.png', + }, + { id: 80, name: 'Adult Swim', logoPath: '/9AKyspxVzywuaMuZ1Bvilu8sXly.png' }, + { id: 13, name: 'Nickelodeon', logoPath: '/ikZXxg6GnwpzqiZbRPhJGaZapqB.png' }, + { id: 3353, name: 'Peacock', logoPath: '/gIAcGTjKKr0KOHL5s4O36roJ8p7.png' }, +]; + +export const parseDiscoverNetworks = ( + data?: string | null +): DiscoverNetwork[] => parseBrandList(data, defaultDiscoverNetworks); + +export const isDefaultDiscoverNetworks = ( + networks: DiscoverNetwork[] +): boolean => isDefaultBrandList(networks, defaultDiscoverNetworks); + +export const serializeDiscoverNetworks = ( + networks: DiscoverNetwork[] +): string | null => serializeBrandList(networks, defaultDiscoverNetworks); diff --git a/src/components/Discover/studios.ts b/src/components/Discover/studios.ts new file mode 100644 index 0000000000..05455f8be0 --- /dev/null +++ b/src/components/Discover/studios.ts @@ -0,0 +1,48 @@ +import { + isDefaultBrandList, + parseBrandList, + serializeBrandList, + type DiscoverBrand, +} from '@app/components/Discover/brandLists'; + +export type DiscoverStudio = DiscoverBrand; + +export const defaultDiscoverStudios: DiscoverStudio[] = [ + { id: 2, name: 'Disney', logoPath: '/wdrCwmRnLFJhEoH8GSfymY85KHT.png' }, + { + id: 127928, + name: '20th Century Studios', + logoPath: '/h0rjX5vjW5r8yEnUBStFarjcLT4.png', + }, + { + id: 34, + name: 'Sony Pictures', + logoPath: '/GagSvqWlyPdkFHMfQ3pNq6ix9P.png', + }, + { + id: 174, + name: 'Warner Bros. Pictures', + logoPath: '/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png', + }, + { id: 33, name: 'Universal', logoPath: '/8lvHyhjr8oUKOOy2dKXoALWKdp0.png' }, + { id: 4, name: 'Paramount', logoPath: '/fycMZt242LVjagMByZOLUGbCvv3.png' }, + { id: 3, name: 'Pixar', logoPath: '/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png' }, + { id: 521, name: 'Dreamworks', logoPath: '/kP7t6RwGz2AvvTkvnI1uteEwHet.png' }, + { + id: 420, + name: 'Marvel Studios', + logoPath: '/hUzeosd33nzE5MCNsZxCGEKTXaQ.png', + }, + { id: 9993, name: 'DC', logoPath: '/2Tc1P3Ac8M479naPp1kYT3izLS5.png' }, + { id: 41077, name: 'A24', logoPath: '/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png' }, +]; + +export const parseDiscoverStudios = (data?: string | null): DiscoverStudio[] => + parseBrandList(data, defaultDiscoverStudios); + +export const isDefaultDiscoverStudios = (studios: DiscoverStudio[]): boolean => + isDefaultBrandList(studios, defaultDiscoverStudios); + +export const serializeDiscoverStudios = ( + studios: DiscoverStudio[] +): string | null => serializeBrandList(studios, defaultDiscoverStudios); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 4a1a0eec9d..5fff82dc60 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -104,11 +104,39 @@ "components.Discover.FilterSlideover.voteCount": "Number of votes between {minValue} and {maxValue}", "components.Discover.MovieGenreList.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", + "components.Discover.NetworkSlider.Editor.addNetwork": "Add", + "components.Discover.NetworkSlider.Editor.duplicateNetwork": "{name} is already in the list.", + "components.Discover.NetworkSlider.Editor.editNetworks": "Displayed networks", + "components.Discover.NetworkSlider.Editor.invalidNetworkId": "Enter a TMDB Network ID.", + "components.Discover.NetworkSlider.Editor.moveDown": "Move {name} down", + "components.Discover.NetworkSlider.Editor.moveUp": "Move {name} up", + "components.Discover.NetworkSlider.Editor.networkAdded": "{name} has been added to the list.", + "components.Discover.NetworkSlider.Editor.networkIdPlaceholder": "TMDB Network ID", + "components.Discover.NetworkSlider.Editor.networkLookupFailed": "Could not find that network.", + "components.Discover.NetworkSlider.Editor.networkMissingLogo": "{name} has no logo, so it cannot be added.", + "components.Discover.NetworkSlider.Editor.removeNetwork": "Remove {name}", + "components.Discover.NetworkSlider.Editor.resetToDefaultNetworks": "Reset to Default Networks", + "components.Discover.NetworkSlider.Editor.tooManyNetworks": "A network list can contain at most {max} networks.", + "components.Discover.NetworkSlider.emptyNetworks": "No networks selected.", "components.Discover.NetworkSlider.networks": "Networks", "components.Discover.PlexWatchlistSlider.emptywatchlist": "Media added to your Plex Watchlist will appear here.", "components.Discover.PlexWatchlistSlider.plexwatchlist": "Your Watchlist", "components.Discover.RecentRequestsSlider.unableToConnect": "Unable to connect to {services}. Some information may be unavailable.", "components.Discover.RecentlyAddedSlider.recentlyAdded": "Recently Added", + "components.Discover.StudioSlider.Editor.addStudio": "Add", + "components.Discover.StudioSlider.Editor.duplicateStudio": "{name} is already in the list.", + "components.Discover.StudioSlider.Editor.editStudios": "Displayed studios", + "components.Discover.StudioSlider.Editor.invalidStudioId": "Enter a TMDB Studio ID.", + "components.Discover.StudioSlider.Editor.moveDown": "Move {name} down", + "components.Discover.StudioSlider.Editor.moveUp": "Move {name} up", + "components.Discover.StudioSlider.Editor.removeStudio": "Remove {name}", + "components.Discover.StudioSlider.Editor.resetToDefaultStudios": "Reset to Default Studios", + "components.Discover.StudioSlider.Editor.studioAdded": "{name} has been added to the list.", + "components.Discover.StudioSlider.Editor.studioIdPlaceholder": "TMDB Studio ID", + "components.Discover.StudioSlider.Editor.studioLookupFailed": "Could not find that studio.", + "components.Discover.StudioSlider.Editor.studioMissingLogo": "{name} has no logo, so it cannot be added.", + "components.Discover.StudioSlider.Editor.tooManyStudios": "A studio list can contain at most {max} studios.", + "components.Discover.StudioSlider.emptyStudios": "No studios selected.", "components.Discover.StudioSlider.studios": "Studios", "components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres", @@ -116,6 +144,8 @@ "components.Discover.customizediscover": "Customize Discover", "components.Discover.discover": "Discover", "components.Discover.emptywatchlist": "Media added to your Plex Watchlist will appear here.", + "components.Discover.invalidnetworklist": "The list of networks is not valid.", + "components.Discover.invalidstudiolist": "The list of studios is not valid.", "components.Discover.moviegenres": "Movie Genres", "components.Discover.networks": "Networks", "components.Discover.plexwatchlist": "Your Watchlist", @@ -140,6 +170,8 @@ "components.Discover.tmdbtvgenre": "TMDB Series Genre", "components.Discover.tmdbtvkeyword": "TMDB Series Keyword", "components.Discover.tmdbtvstreamingservices": "TMDB TV Streaming Services", + "components.Discover.toomanynetworks": "A network list can contain at most {maxNetworks} networks.", + "components.Discover.toomanystudios": "A studio list can contain at most {maxStudios} studios.", "components.Discover.trending": "Trending", "components.Discover.tvgenres": "Series Genres", "components.Discover.upcoming": "Upcoming Movies",