From 540e641f7c8f7e24abe1aadb357a98381ae0ebb4 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Wed, 30 Sep 2026 16:31:15 -0600 Subject: [PATCH 1/7] feat(discover): validate and store a custom networks list --- server/constants/discover.ts | 33 +++ server/constants/error.ts | 2 + server/entity/DiscoverSlider.ts | 4 +- server/lib/discoverNetworks.test.ts | 204 ++++++++++++++++++ server/lib/discoverNetworks.ts | 49 +++++ ...300000000-AlterDiscoverSliderDataToText.ts | 19 ++ ...300000000-AlterDiscoverSliderDataToText.ts | 101 +++++++++ 7 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 server/lib/discoverNetworks.test.ts create mode 100644 server/lib/discoverNetworks.ts create mode 100644 server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts create mode 100644 server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts diff --git a/server/constants/discover.ts b/server/constants/discover.ts index fda0682243..2f50462de0 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,38 @@ export enum DiscoverSliderType { TMDB_TV_STREAMING_SERVICES, } +export const MAX_DISCOVER_NETWORKS = 50; +export const MAX_DISCOVER_NETWORK_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_NETWORK_LOGO_PATH = /^\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; + +// Shared by the server (when saving) and the client (when reading and adding +// networks) so both sides accept exactly the same list. +export const discoverNetworkSchema = z.object({ + id: z.number().int().positive(), + name: z.string().trim().min(1).max(MAX_DISCOVER_NETWORK_NAME_LENGTH), + logoPath: z.string().regex(DISCOVER_NETWORK_LOGO_PATH), +}); + +// Also drops networks with an id that is already in the list +export const discoverNetworksSchema = z + .array(discoverNetworkSchema) + .transform((networks) => { + const seen = new Set(); + + return networks.filter((network) => { + if (seen.has(network.id)) { + return false; + } + + seen.add(network.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..883a55d536 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -8,6 +8,8 @@ export enum ApiErrorCode { ConnectionError = 'CONNECTION_ERROR', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', + InvalidNetworkList = 'INVALID_NETWORK_LIST', + TooManyNetworks = 'TOO_MANY_NETWORKS', 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/discoverNetworks.test.ts b/server/lib/discoverNetworks.test.ts new file mode 100644 index 0000000000..2eaefa41b1 --- /dev/null +++ b/server/lib/discoverNetworks.test.ts @@ -0,0 +1,204 @@ +import { + discoverNetworksSchema, + 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 = discoverNetworksSchema.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..57ffb7816d --- /dev/null +++ b/server/lib/discoverNetworks.ts @@ -0,0 +1,49 @@ +import { + discoverNetworksSchema, + MAX_DISCOVER_NETWORKS, +} from '@server/constants/discover'; +import { ApiErrorCode } from '@server/constants/error'; + +// 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 => { + if (data === null || data === '') { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(data); + } catch { + throw new InvalidDiscoverNetworksError(); + } + + const result = discoverNetworksSchema.safeParse(parsed); + + if (!result.success) { + throw new InvalidDiscoverNetworksError(); + } + + if (result.data.length > MAX_DISCOVER_NETWORKS) { + throw new TooManyDiscoverNetworksError(); + } + + return JSON.stringify(result.data); +}; diff --git a/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts b/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts new file mode 100644 index 0000000000..2856c4e4a9 --- /dev/null +++ b/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts @@ -0,0 +1,19 @@ +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..76eb69739b --- /dev/null +++ b/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts @@ -0,0 +1,101 @@ +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"`); + } +} From 134dfb154bbd3607393dabcd8816865f3976f58e Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Wed, 30 Sep 2026 16:31:21 -0600 Subject: [PATCH 2/7] feat(api): save the networks list through the discover settings endpoints --- seerr-api.yml | 29 +++ server/routes/settings/discover.test.ts | 323 ++++++++++++++++++++++++ server/routes/settings/discover.ts | 56 +++- 3 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 server/routes/settings/discover.test.ts diff --git a/seerr-api.yml b/seerr-api.yml index 24b825c79c..eba7f4f044 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 slider it is a JSON encoded array of `{ id, name, logoPath }` objects + (at most 50), or `null` to use the default list of networks. example: '1234' nullable: true required: @@ -3910,6 +3914,18 @@ paths: type: array items: $ref: '#/components/schemas/DiscoverSlider' + '400': + description: The request body or the Networks 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. + example: TOO_MANY_NETWORKS /settings/discover/{sliderId}: put: summary: Update a single slider @@ -3938,6 +3954,7 @@ paths: example: 1 data: type: string + nullable: true example: '1' responses: '200': @@ -3946,6 +3963,18 @@ paths: application/json: schema: $ref: '#/components/schemas/DiscoverSlider' + '400': + description: The Networks slider list is invalid + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: | + `TOO_MANY_NETWORKS` or `INVALID_NETWORK_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/routes/settings/discover.test.ts b/server/routes/settings/discover.test.ts new file mode 100644 index 0000000000..452f58268c --- /dev/null +++ b/server/routes/settings/discover.test.ts @@ -0,0 +1,323 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + DiscoverSliderType, + MAX_DISCOVER_NETWORKS, +} 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)) + ); + }); +}); diff --git a/server/routes/settings/discover.ts b/server/routes/settings/discover.ts index 344ce72bea..08521d5ab8 100644 --- a/server/routes/settings/discover.ts +++ b/server/routes/settings/discover.ts @@ -1,8 +1,36 @@ +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 logger from '@server/logger'; import { Router } from 'express'; +const applyNetworkList = ( + existingSlider: DiscoverSlider, + data: string | null | undefined +): string | null => { + if ( + existingSlider.type !== DiscoverSliderType.NETWORKS || + data === undefined + ) { + return null; + } + + try { + existingSlider.data = sanitizeDiscoverNetworks(data); + return null; + } catch (e) { + if (e instanceof InvalidDiscoverNetworksError) { + return e.message; + } + + throw e; + } +}; + const discoverSettingRoutes = Router(); discoverSettingRoutes.post('/', async (req, res) => { @@ -14,6 +42,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 +57,20 @@ 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, whose data is the shared list. if (!existingSlider.isBuiltIn) { existingSlider.title = slider.title; existingSlider.data = slider.data; existingSlider.type = slider.type; + } else { + const networkListError = applyNetworkList(existingSlider, slider.data); + + if (networkListError) { + return res.status(400).json({ message: networkListError }); + } } - await sliderRepository.save(existingSlider); + prepared.push(existingSlider); } else { const newSlider = new DiscoverSlider({ isBuiltIn: false, @@ -43,11 +80,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 +129,17 @@ 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, whose data is the shared list. if (!existingSlider.isBuiltIn) { existingSlider.title = slider.title; existingSlider.data = slider.data; existingSlider.type = slider.type; + } else { + const networkListError = applyNetworkList(existingSlider, slider.data); + + if (networkListError) { + return res.status(400).json({ message: networkListError }); + } } await sliderRepository.save(existingSlider); From b92eb8d90251b5ecd3b64b1918cc78c206b36403 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Wed, 30 Sep 2026 16:31:23 -0600 Subject: [PATCH 3/7] feat(discover): let admins edit the networks row --- .../e2e/settings/discover-customization.cy.ts | 77 +++++ .../Discover/DiscoverSliderEdit/index.tsx | 10 +- .../Discover/NetworkSlider/Editor.tsx | 290 ++++++++++++++++++ .../Discover/NetworkSlider/index.tsx | 177 ++--------- src/components/Discover/index.tsx | 122 ++++++-- src/components/Discover/networks.ts | 94 ++++++ src/i18n/locale/en.json | 16 + 7 files changed, 615 insertions(+), 171 deletions(-) create mode 100644 src/components/Discover/NetworkSlider/Editor.tsx create mode 100644 src/components/Discover/networks.ts diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts index 01487af87a..c1e78d0391 100644 --- a/cypress/e2e/settings/discover-customization.cy.ts +++ b/cypress/e2e/settings/discover-customization.cy.ts @@ -162,4 +162,81 @@ 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'); + }); + }); }); diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index c24a0591ad..fc58eb00f8 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -197,7 +197,11 @@ const DiscoverSliderEdit = ({ )}
@@ -206,7 +210,9 @@ 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..493584be46 --- /dev/null +++ b/src/components/Discover/NetworkSlider/Editor.tsx @@ -0,0 +1,290 @@ +import Button from '@app/components/Common/Button'; +import { + defaultDiscoverNetworks, + isDefaultDiscoverNetworks, + isNetworkLogoPath, + type DiscoverNetwork, +} from '@app/components/Discover/networks'; +import useToasts from '@app/hooks/useToasts'; +import defineMessages from '@app/utils/defineMessages'; +import { + ChevronDownIcon, + ChevronUpIcon, + XMarkIcon, +} from '@heroicons/react/24/solid'; +import { + MAX_DISCOVER_NETWORKS, + MAX_DISCOVER_NETWORK_NAME_LENGTH, + discoverNetworkSchema, +} from '@server/constants/discover'; +import type { TvNetwork } from '@server/models/common'; +import axios from 'axios'; +import { useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Discover.NetworkSlider.Editor', { + editNetworks: 'Displayed networks', + networkIdPlaceholder: 'TMDB Network ID', + addNetwork: 'Add', + resetToDefaultNetworks: 'Reset to Default Networks', + removeNetwork: 'Remove {networkName}', + moveUp: 'Move {networkName} up', + moveDown: 'Move {networkName} down', + invalidNetworkId: 'Enter a TMDB Network ID.', + networkLookupFailed: 'Could not find that network.', + networkMissingLogo: '{networkName} has no logo, so it cannot be added.', + duplicateNetwork: '{networkName} is already in the list.', + networkAdded: '{networkName} has been added to the list.', + tooManyNetworks: 'A network list can contain at most {maxNetworks} networks.', +}); + +interface NetworkSliderEditorProps { + networks: DiscoverNetwork[]; + onChange: (networks: DiscoverNetwork[]) => void; +} + +const NetworkSliderEditor = ({ + networks, + onChange, +}: NetworkSliderEditorProps) => { + const intl = useIntl(); + const { addToast } = useToasts(); + const [networkId, setNetworkId] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const networksRef = useRef(networks); + const isMounted = useRef(true); + + useEffect(() => { + networksRef.current = networks; + }, [networks]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + const canAddNetwork = (list: DiscoverNetwork[], id: number): boolean => { + const existing = list.find((network) => network.id === id); + + if (existing) { + addToast( + intl.formatMessage(messages.duplicateNetwork, { + networkName: existing.name, + }), + { + appearance: 'error', + autoDismiss: true, + } + ); + return false; + } + + if (list.length >= MAX_DISCOVER_NETWORKS) { + addToast( + intl.formatMessage(messages.tooManyNetworks, { + maxNetworks: MAX_DISCOVER_NETWORKS, + }), + { + appearance: 'error', + autoDismiss: true, + } + ); + return false; + } + + return true; + }; + + const moveNetwork = (index: number, direction: -1 | 1) => { + const nextIndex = index + direction; + + if (nextIndex < 0 || nextIndex >= networks.length) { + return; + } + + const reordered = networks.slice(); + const [moved] = reordered.splice(index, 1); + + if (!moved) { + return; + } + + reordered.splice(nextIndex, 0, moved); + onChange(reordered); + }; + + const addNetwork = async () => { + const id = Number(networkId.trim()); + + if (!Number.isInteger(id) || id <= 0) { + addToast(intl.formatMessage(messages.invalidNetworkId), { + appearance: 'error', + autoDismiss: true, + }); + return; + } + + if (!canAddNetwork(networks, id)) { + return; + } + + setIsAdding(true); + + try { + const response = await axios.get(`/api/v1/network/${id}`); + const network = 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 currentNetworks = networksRef.current; + + if (!canAddNetwork(currentNetworks, network.id)) { + return; + } + + if (!network.logoPath || !isNetworkLogoPath(network.logoPath)) { + addToast( + intl.formatMessage(messages.networkMissingLogo, { + networkName: network.name, + }), + { + appearance: 'error', + autoDismiss: true, + } + ); + return; + } + + // Use the same rules as the server so a network that is added here can + // always be saved. A very long name is shortened instead of rejected. + const validated = discoverNetworkSchema.safeParse({ + id: network.id, + name: network.name?.trim().slice(0, MAX_DISCOVER_NETWORK_NAME_LENGTH), + logoPath: network.logoPath, + }); + + if (!validated.success) { + addToast(intl.formatMessage(messages.networkLookupFailed), { + appearance: 'error', + autoDismiss: true, + }); + return; + } + + onChange([...currentNetworks, validated.data]); + setNetworkId(''); + addToast( + intl.formatMessage(messages.networkAdded, { + networkName: validated.data.name, + }), + { + appearance: 'success', + autoDismiss: true, + } + ); + } catch { + if (isMounted.current) { + addToast(intl.formatMessage(messages.networkLookupFailed), { + appearance: 'error', + autoDismiss: true, + }); + } + } finally { + if (isMounted.current) { + setIsAdding(false); + } + } + }; + + return ( +
+
+ + {intl.formatMessage(messages.editNetworks)} + + +
+
    + {networks.map((network, index) => ( +
  • + {network.name} + + + +
  • + ))} +
+
{ + event.preventDefault(); + void addNetwork(); + }} + > + setNetworkId(event.target.value)} + /> + +
+
+ ); +}; + +export default NetworkSliderEditor; diff --git a/src/components/Discover/NetworkSlider/index.tsx b/src/components/Discover/NetworkSlider/index.tsx index f19cce2c18..83921f64eb 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 NetworkSliderEditor from '@app/components/Discover/NetworkSlider/Editor'; +import { + networkLogoUrl, + 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 = () => { ( + isEmpty={networks.length === 0} + items={networks.map((network) => ( ))} - emptyMessage="" + emptyMessage={intl.formatMessage(messages.emptyNetworks)} /> ); diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 2a6313bafb..1ec6c29246 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -27,7 +27,11 @@ import { PencilIcon, PlusIcon, } from '@heroicons/react/24/solid'; -import { DiscoverSliderType } from '@server/constants/discover'; +import { + DiscoverSliderType, + MAX_DISCOVER_NETWORKS, +} 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 +51,51 @@ 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.', 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 list) 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 +118,16 @@ const Discover = () => { const hasChanged = () => !Object.is(discoverData, sliders); + 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 +138,24 @@ const Discover = () => { }); setIsEditing(false); mutate(); - } catch { - addToast(intl.formatMessage(messages.updatefailed), { + } catch (error) { + // A 400 carries an error code when the networks 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); + } + + addToast(toastMessage, { appearance: 'error', autoDismiss: true, }); @@ -134,15 +204,7 @@ const Discover = () => {
- { - const newSliders = await mutate(); - - if (newSliders) { - setSliders(newSliders); - } - }} - /> +
)} @@ -283,7 +345,21 @@ const Discover = () => { ); break; case DiscoverSliderType.NETWORKS: - sliderComponent = ; + sliderComponent = ( + + setSliders((currentSliders) => + currentSliders.map((currentSlider) => + currentSlider.id === slider.id + ? { ...currentSlider, data } + : currentSlider + ) + ) + } + /> + ); break; case DiscoverSliderType.TMDB_MOVIE_KEYWORD: sliderComponent = ( @@ -405,17 +481,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..1a01ee23fb --- /dev/null +++ b/src/components/Discover/networks.ts @@ -0,0 +1,94 @@ +import { + DISCOVER_NETWORK_LOGO_PATH, + discoverNetworksSchema, +} from '@server/constants/discover'; + +export interface DiscoverNetwork { + id: number; + name: string; + logoPath: string; +} + +export const isNetworkLogoPath = (logoPath: string): boolean => + DISCOVER_NETWORK_LOGO_PATH.test(logoPath); + +export const networkLogoUrl = (logoPath: string): string => + `https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${logoPath}`; + +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[] => { + if (!data) { + return defaultDiscoverNetworks; + } + + try { + // The same schema the server uses, so names are trimmed and duplicate ids + // are dropped here too + const result = discoverNetworksSchema.safeParse(JSON.parse(data)); + + return result.success ? result.data : defaultDiscoverNetworks; + } catch { + return defaultDiscoverNetworks; + } +}; + +export const isDefaultDiscoverNetworks = ( + networks: DiscoverNetwork[] +): boolean => + networks.length === defaultDiscoverNetworks.length && + networks.every((network, index) => { + const fallback = defaultDiscoverNetworks[index]; + + return ( + network.id === fallback.id && + network.name === fallback.name && + network.logoPath === fallback.logoPath + ); + }); + +export const serializeDiscoverNetworks = ( + networks: DiscoverNetwork[] +): string | null => + isDefaultDiscoverNetworks(networks) ? null : JSON.stringify(networks); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 4a1a0eec9d..529a05ede5 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -104,6 +104,20 @@ "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": "{networkName} 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 {networkName} down", + "components.Discover.NetworkSlider.Editor.moveUp": "Move {networkName} up", + "components.Discover.NetworkSlider.Editor.networkAdded": "{networkName} 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": "{networkName} has no logo, so it cannot be added.", + "components.Discover.NetworkSlider.Editor.removeNetwork": "Remove {networkName}", + "components.Discover.NetworkSlider.Editor.resetToDefaultNetworks": "Reset to Default Networks", + "components.Discover.NetworkSlider.Editor.tooManyNetworks": "A network list can contain at most {maxNetworks} 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", @@ -116,6 +130,7 @@ "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.moviegenres": "Movie Genres", "components.Discover.networks": "Networks", "components.Discover.plexwatchlist": "Your Watchlist", @@ -140,6 +155,7 @@ "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.trending": "Trending", "components.Discover.tvgenres": "Series Genres", "components.Discover.upcoming": "Upcoming Movies", From d8128e93d546cce3f33050092eda428ed6730832 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Sun, 11 Oct 2026 14:29:06 -0600 Subject: [PATCH 4/7] refactor(discover): share the networks list code with other brand lists The Studios row shows the same kind of list as the Networks row: brands with an id, a name and a logo. Move the parts that do not depend on networks into shared code so both rows can use it. --- server/constants/discover.ts | 30 +- server/lib/discoverBrands.ts | 44 +++ server/lib/discoverNetworks.test.ts | 4 +- server/lib/discoverNetworks.ts | 39 +-- .../{CompanyCard => BrandCard}/index.tsx | 6 +- .../Discover/BrandListEditor/index.tsx | 281 ++++++++++++++++ .../Discover/NetworkSlider/Editor.tsx | 299 ++---------------- .../Discover/NetworkSlider/index.tsx | 8 +- .../Discover/StudioSlider/index.tsx | 4 +- src/components/Discover/brandLists.ts | 60 ++++ src/components/Discover/networks.ts | 51 +-- src/i18n/locale/en.json | 14 +- 12 files changed, 470 insertions(+), 370 deletions(-) create mode 100644 server/lib/discoverBrands.ts rename src/components/{CompanyCard => BrandCard}/index.tsx (91%) create mode 100644 src/components/Discover/BrandListEditor/index.tsx create mode 100644 src/components/Discover/brandLists.ts diff --git a/server/constants/discover.ts b/server/constants/discover.ts index 2f50462de0..c0b9f68c67 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -26,32 +26,34 @@ export enum DiscoverSliderType { } export const MAX_DISCOVER_NETWORKS = 50; -export const MAX_DISCOVER_NETWORK_NAME_LENGTH = 200; +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_NETWORK_LOGO_PATH = /^\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; +export const DISCOVER_BRAND_LOGO_PATH = /^\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; -// Shared by the server (when saving) and the client (when reading and adding -// networks) so both sides accept exactly the same list. -export const discoverNetworkSchema = z.object({ +// 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_NETWORK_NAME_LENGTH), - logoPath: z.string().regex(DISCOVER_NETWORK_LOGO_PATH), + name: z.string().trim().min(1).max(MAX_DISCOVER_BRAND_NAME_LENGTH), + logoPath: z.string().regex(DISCOVER_BRAND_LOGO_PATH), }); -// Also drops networks with an id that is already in the list -export const discoverNetworksSchema = z - .array(discoverNetworkSchema) - .transform((networks) => { +// 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 networks.filter((network) => { - if (seen.has(network.id)) { + return brands.filter((brand) => { + if (seen.has(brand.id)) { return false; } - seen.add(network.id); + seen.add(brand.id); return true; }); 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 index 2eaefa41b1..c93ef4ed45 100644 --- a/server/lib/discoverNetworks.test.ts +++ b/server/lib/discoverNetworks.test.ts @@ -1,5 +1,5 @@ import { - discoverNetworksSchema, + discoverBrandsSchema, MAX_DISCOVER_NETWORKS, } from '@server/constants/discover'; import { ApiErrorCode } from '@server/constants/error'; @@ -124,7 +124,7 @@ describe('sanitizeDiscoverNetworks', () => { }); it('gives the client the same trimmed, deduplicated list as the server', () => { - const result = discoverNetworksSchema.safeParse([ + const result = discoverBrandsSchema.safeParse([ { id: 1, name: ' Netflix ', logoPath: '/logo.png' }, { id: 1, name: 'Netflix again', logoPath: '/other.png' }, ]); diff --git a/server/lib/discoverNetworks.ts b/server/lib/discoverNetworks.ts index 57ffb7816d..8bbd43c561 100644 --- a/server/lib/discoverNetworks.ts +++ b/server/lib/discoverNetworks.ts @@ -1,8 +1,6 @@ -import { - discoverNetworksSchema, - MAX_DISCOVER_NETWORKS, -} from '@server/constants/discover'; +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. @@ -20,30 +18,9 @@ export class TooManyDiscoverNetworksError extends InvalidDiscoverNetworksError { } } -export const sanitizeDiscoverNetworks = ( - data: string | null -): string | null => { - if (data === null || data === '') { - return null; - } - - let parsed: unknown; - - try { - parsed = JSON.parse(data); - } catch { - throw new InvalidDiscoverNetworksError(); - } - - const result = discoverNetworksSchema.safeParse(parsed); - - if (!result.success) { - throw new InvalidDiscoverNetworksError(); - } - - if (result.data.length > MAX_DISCOVER_NETWORKS) { - throw new TooManyDiscoverNetworksError(); - } - - return JSON.stringify(result.data); -}; +export const sanitizeDiscoverNetworks = (data: string | null): string | null => + sanitizeDiscoverBrandList(data, { + maxBrands: MAX_DISCOVER_NETWORKS, + createInvalidError: () => new InvalidDiscoverNetworksError(), + createTooManyError: () => new TooManyDiscoverNetworksError(), + }); 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/NetworkSlider/Editor.tsx b/src/components/Discover/NetworkSlider/Editor.tsx index 493584be46..60f7c2f8c1 100644 --- a/src/components/Discover/NetworkSlider/Editor.tsx +++ b/src/components/Discover/NetworkSlider/Editor.tsx @@ -1,41 +1,25 @@ -import Button from '@app/components/Common/Button'; +import BrandListEditor from '@app/components/Discover/BrandListEditor'; import { defaultDiscoverNetworks, - isDefaultDiscoverNetworks, - isNetworkLogoPath, type DiscoverNetwork, } from '@app/components/Discover/networks'; -import useToasts from '@app/hooks/useToasts'; import defineMessages from '@app/utils/defineMessages'; -import { - ChevronDownIcon, - ChevronUpIcon, - XMarkIcon, -} from '@heroicons/react/24/solid'; -import { - MAX_DISCOVER_NETWORKS, - MAX_DISCOVER_NETWORK_NAME_LENGTH, - discoverNetworkSchema, -} from '@server/constants/discover'; -import type { TvNetwork } from '@server/models/common'; -import axios from 'axios'; -import { useEffect, useRef, useState } from 'react'; -import { useIntl } from 'react-intl'; +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 {networkName}', - moveUp: 'Move {networkName} up', - moveDown: 'Move {networkName} down', + removeNetwork: 'Remove {name}', + moveUp: 'Move {name} up', + moveDown: 'Move {name} down', invalidNetworkId: 'Enter a TMDB Network ID.', networkLookupFailed: 'Could not find that network.', - networkMissingLogo: '{networkName} has no logo, so it cannot be added.', - duplicateNetwork: '{networkName} is already in the list.', - networkAdded: '{networkName} has been added to the list.', - tooManyNetworks: 'A network list can contain at most {maxNetworks} networks.', + 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 { @@ -46,245 +30,30 @@ interface NetworkSliderEditorProps { const NetworkSliderEditor = ({ networks, onChange, -}: NetworkSliderEditorProps) => { - const intl = useIntl(); - const { addToast } = useToasts(); - const [networkId, setNetworkId] = useState(''); - const [isAdding, setIsAdding] = useState(false); - const networksRef = useRef(networks); - const isMounted = useRef(true); - - useEffect(() => { - networksRef.current = networks; - }, [networks]); - - useEffect(() => { - isMounted.current = true; - - return () => { - isMounted.current = false; - }; - }, []); - - const canAddNetwork = (list: DiscoverNetwork[], id: number): boolean => { - const existing = list.find((network) => network.id === id); - - if (existing) { - addToast( - intl.formatMessage(messages.duplicateNetwork, { - networkName: existing.name, - }), - { - appearance: 'error', - autoDismiss: true, - } - ); - return false; - } - - if (list.length >= MAX_DISCOVER_NETWORKS) { - addToast( - intl.formatMessage(messages.tooManyNetworks, { - maxNetworks: MAX_DISCOVER_NETWORKS, - }), - { - appearance: 'error', - autoDismiss: true, - } - ); - return false; - } - - return true; - }; - - const moveNetwork = (index: number, direction: -1 | 1) => { - const nextIndex = index + direction; - - if (nextIndex < 0 || nextIndex >= networks.length) { - return; - } - - const reordered = networks.slice(); - const [moved] = reordered.splice(index, 1); - - if (!moved) { - return; - } - - reordered.splice(nextIndex, 0, moved); - onChange(reordered); - }; - - const addNetwork = async () => { - const id = Number(networkId.trim()); - - if (!Number.isInteger(id) || id <= 0) { - addToast(intl.formatMessage(messages.invalidNetworkId), { - appearance: 'error', - autoDismiss: true, - }); - return; - } - - if (!canAddNetwork(networks, id)) { - return; - } - - setIsAdding(true); - - try { - const response = await axios.get(`/api/v1/network/${id}`); - const network = 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 currentNetworks = networksRef.current; - - if (!canAddNetwork(currentNetworks, network.id)) { - return; - } - - if (!network.logoPath || !isNetworkLogoPath(network.logoPath)) { - addToast( - intl.formatMessage(messages.networkMissingLogo, { - networkName: network.name, - }), - { - appearance: 'error', - autoDismiss: true, - } - ); - return; - } - - // Use the same rules as the server so a network that is added here can - // always be saved. A very long name is shortened instead of rejected. - const validated = discoverNetworkSchema.safeParse({ - id: network.id, - name: network.name?.trim().slice(0, MAX_DISCOVER_NETWORK_NAME_LENGTH), - logoPath: network.logoPath, - }); - - if (!validated.success) { - addToast(intl.formatMessage(messages.networkLookupFailed), { - appearance: 'error', - autoDismiss: true, - }); - return; - } - - onChange([...currentNetworks, validated.data]); - setNetworkId(''); - addToast( - intl.formatMessage(messages.networkAdded, { - networkName: validated.data.name, - }), - { - appearance: 'success', - autoDismiss: true, - } - ); - } catch { - if (isMounted.current) { - addToast(intl.formatMessage(messages.networkLookupFailed), { - appearance: 'error', - autoDismiss: true, - }); - } - } finally { - if (isMounted.current) { - setIsAdding(false); - } - } - }; - - return ( -
-
- - {intl.formatMessage(messages.editNetworks)} - - -
-
    - {networks.map((network, index) => ( -
  • - {network.name} - - - -
  • - ))} -
-
{ - event.preventDefault(); - void addNetwork(); - }} - > - setNetworkId(event.target.value)} - /> - -
-
- ); -}; +}: NetworkSliderEditorProps) => ( + +); export default NetworkSliderEditor; diff --git a/src/components/Discover/NetworkSlider/index.tsx b/src/components/Discover/NetworkSlider/index.tsx index 83921f64eb..b748cf07b4 100644 --- a/src/components/Discover/NetworkSlider/index.tsx +++ b/src/components/Discover/NetworkSlider/index.tsx @@ -1,7 +1,7 @@ -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 { - networkLogoUrl, parseDiscoverNetworks, serializeDiscoverNetworks, } from '@app/components/Discover/networks'; @@ -48,10 +48,10 @@ const NetworkSlider = ({ isLoading={false} isEmpty={networks.length === 0} items={networks.map((network) => ( - ))} diff --git a/src/components/Discover/StudioSlider/index.tsx b/src/components/Discover/StudioSlider/index.tsx index 6b23f4bdd9..de45f937e4 100644 --- a/src/components/Discover/StudioSlider/index.tsx +++ b/src/components/Discover/StudioSlider/index.tsx @@ -1,4 +1,4 @@ -import CompanyCard from '@app/components/CompanyCard'; +import BrandCard from '@app/components/BrandCard'; import Slider from '@app/components/Slider'; import defineMessages from '@app/utils/defineMessages'; import { useIntl } from 'react-intl'; @@ -97,7 +97,7 @@ const StudioSlider = () => { isLoading={false} isEmpty={false} items={studios.map((studio, index) => ( - + 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/networks.ts b/src/components/Discover/networks.ts index 1a01ee23fb..ff7d01f4dd 100644 --- a/src/components/Discover/networks.ts +++ b/src/components/Discover/networks.ts @@ -1,19 +1,11 @@ import { - DISCOVER_NETWORK_LOGO_PATH, - discoverNetworksSchema, -} from '@server/constants/discover'; + isDefaultBrandList, + parseBrandList, + serializeBrandList, + type DiscoverBrand, +} from '@app/components/Discover/brandLists'; -export interface DiscoverNetwork { - id: number; - name: string; - logoPath: string; -} - -export const isNetworkLogoPath = (logoPath: string): boolean => - DISCOVER_NETWORK_LOGO_PATH.test(logoPath); - -export const networkLogoUrl = (logoPath: string): string => - `https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${logoPath}`; +export type DiscoverNetwork = DiscoverBrand; export const defaultDiscoverNetworks: DiscoverNetwork[] = [ { id: 213, name: 'Netflix', logoPath: '/wwemzKWzjKYJFfCeiB57q3r4Bcm.png' }, @@ -58,37 +50,12 @@ export const defaultDiscoverNetworks: DiscoverNetwork[] = [ export const parseDiscoverNetworks = ( data?: string | null -): DiscoverNetwork[] => { - if (!data) { - return defaultDiscoverNetworks; - } - - try { - // The same schema the server uses, so names are trimmed and duplicate ids - // are dropped here too - const result = discoverNetworksSchema.safeParse(JSON.parse(data)); - - return result.success ? result.data : defaultDiscoverNetworks; - } catch { - return defaultDiscoverNetworks; - } -}; +): DiscoverNetwork[] => parseBrandList(data, defaultDiscoverNetworks); export const isDefaultDiscoverNetworks = ( networks: DiscoverNetwork[] -): boolean => - networks.length === defaultDiscoverNetworks.length && - networks.every((network, index) => { - const fallback = defaultDiscoverNetworks[index]; - - return ( - network.id === fallback.id && - network.name === fallback.name && - network.logoPath === fallback.logoPath - ); - }); +): boolean => isDefaultBrandList(networks, defaultDiscoverNetworks); export const serializeDiscoverNetworks = ( networks: DiscoverNetwork[] -): string | null => - isDefaultDiscoverNetworks(networks) ? null : JSON.stringify(networks); +): string | null => serializeBrandList(networks, defaultDiscoverNetworks); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 529a05ede5..b19c0a5d74 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -105,18 +105,18 @@ "components.Discover.MovieGenreList.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", "components.Discover.NetworkSlider.Editor.addNetwork": "Add", - "components.Discover.NetworkSlider.Editor.duplicateNetwork": "{networkName} is already in the list.", + "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 {networkName} down", - "components.Discover.NetworkSlider.Editor.moveUp": "Move {networkName} up", - "components.Discover.NetworkSlider.Editor.networkAdded": "{networkName} has been added to the list.", + "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": "{networkName} has no logo, so it cannot be added.", - "components.Discover.NetworkSlider.Editor.removeNetwork": "Remove {networkName}", + "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 {maxNetworks} 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.", From ec27f92cd2583146183204e51e3f4d8ea2898008 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Sun, 11 Oct 2026 14:29:39 -0600 Subject: [PATCH 5/7] feat(api): validate and save a custom studios list Let the built-in Studios slider keep an admin-edited list, the same way the Networks slider does. --- seerr-api.yml | 13 +- server/constants/discover.ts | 1 + server/constants/error.ts | 2 + server/lib/discoverStudios.test.ts | 101 ++++++++++++++++ server/lib/discoverStudios.ts | 26 ++++ server/routes/settings/discover.test.ts | 154 ++++++++++++++++++++++++ server/routes/settings/discover.ts | 48 +++++--- 7 files changed, 324 insertions(+), 21 deletions(-) create mode 100644 server/lib/discoverStudios.test.ts create mode 100644 server/lib/discoverStudios.ts diff --git a/seerr-api.yml b/seerr-api.yml index eba7f4f044..4672ba3cd7 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -2109,8 +2109,8 @@ components: 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 slider it is a JSON encoded array of `{ id, name, logoPath }` objects - (at most 50), or `null` to use the default list of networks. + 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: @@ -3915,7 +3915,7 @@ paths: items: $ref: '#/components/schemas/DiscoverSlider' '400': - description: The request body or the Networks slider list is invalid + description: The request body or the Networks or Studios slider list is invalid content: application/json: schema: @@ -3924,7 +3924,8 @@ paths: message: type: string description: | - `TOO_MANY_NETWORKS` or `INVALID_NETWORK_LIST` when the Networks slider list is rejected. + `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: @@ -3964,7 +3965,7 @@ paths: schema: $ref: '#/components/schemas/DiscoverSlider' '400': - description: The Networks slider list is invalid + description: The Networks or Studios slider list is invalid content: application/json: schema: @@ -3973,7 +3974,7 @@ paths: message: type: string description: | - `TOO_MANY_NETWORKS` or `INVALID_NETWORK_LIST`. + `TOO_MANY_NETWORKS`, `INVALID_NETWORK_LIST`, `TOO_MANY_STUDIOS` or `INVALID_STUDIO_LIST`. example: INVALID_NETWORK_LIST delete: summary: Delete slider by ID diff --git a/server/constants/discover.ts b/server/constants/discover.ts index c0b9f68c67..65390d4ac0 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -26,6 +26,7 @@ export enum DiscoverSliderType { } 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 diff --git a/server/constants/error.ts b/server/constants/error.ts index 883a55d536..a7972f0c2f 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -10,6 +10,8 @@ export enum ApiErrorCode { 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/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/routes/settings/discover.test.ts b/server/routes/settings/discover.test.ts index 452f58268c..c5f57372a2 100644 --- a/server/routes/settings/discover.test.ts +++ b/server/routes/settings/discover.test.ts @@ -4,6 +4,7 @@ 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'; @@ -321,3 +322,156 @@ describe('POST /discover network list', () => { ); }); }); + +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 08521d5ab8..9865c3c735 100644 --- a/server/routes/settings/discover.ts +++ b/server/routes/settings/discover.ts @@ -5,25 +5,41 @@ import { InvalidDiscoverNetworksError, sanitizeDiscoverNetworks, } from '@server/lib/discoverNetworks'; +import { + InvalidDiscoverStudiosError, + sanitizeDiscoverStudios, +} from '@server/lib/discoverStudios'; import logger from '@server/logger'; import { Router } from 'express'; -const applyNetworkList = ( +// 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 => { - if ( - existingSlider.type !== DiscoverSliderType.NETWORKS || - data === undefined - ) { + const sanitize = brandListSanitizers[existingSlider.type]; + + if (!sanitize || data === undefined) { return null; } try { - existingSlider.data = sanitizeDiscoverNetworks(data); + existingSlider.data = sanitize(data); return null; } catch (e) { - if (e instanceof InvalidDiscoverNetworksError) { + if ( + e instanceof InvalidDiscoverNetworksError || + e instanceof InvalidDiscoverStudiosError + ) { return e.message; } @@ -57,16 +73,17 @@ discoverSettingRoutes.post('/', async (req, res) => { existingSlider.enabled = slider.enabled; existingSlider.order = x; - // Built-in sliders are fixed, except Networks, whose data is the shared list. + // 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 networkListError = applyNetworkList(existingSlider, slider.data); + const brandListError = applyBrandList(existingSlider, slider.data); - if (networkListError) { - return res.status(400).json({ message: networkListError }); + if (brandListError) { + return res.status(400).json({ message: brandListError }); } } @@ -129,16 +146,17 @@ discoverSettingRoutes.put('/:sliderId', async (req, res, next) => { }, }); - // Built-in sliders are fixed, except Networks, whose data is the shared list. + // 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 networkListError = applyNetworkList(existingSlider, slider.data); + const brandListError = applyBrandList(existingSlider, slider.data); - if (networkListError) { - return res.status(400).json({ message: networkListError }); + if (brandListError) { + return res.status(400).json({ message: brandListError }); } } From d8f4fdbb5e46d13380f1f32e60480774c5a1f807 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Sun, 11 Oct 2026 14:30:05 -0600 Subject: [PATCH 6/7] feat(discover): let admins edit the studios row Let admins choose which studios the Studios row shows, the same way they choose networks. While customizing Discover, the row shows an editor to add a studio by its TMDB ID, remove or reorder studios, and reset to the default studios. --- .../e2e/settings/discover-customization.cy.ts | 98 ++++++++++++++++ .../Discover/DiscoverSliderEdit/index.tsx | 17 +-- .../Discover/StudioSlider/Editor.tsx | 56 +++++++++ .../Discover/StudioSlider/index.tsx | 111 +++++------------- src/components/Discover/index.tsx | 45 +++++-- src/components/Discover/studios.ts | 48 ++++++++ src/i18n/locale/en.json | 16 +++ 7 files changed, 291 insertions(+), 100 deletions(-) create mode 100644 src/components/Discover/StudioSlider/Editor.tsx create mode 100644 src/components/Discover/studios.ts diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts index c1e78d0391..c48ce998d9 100644 --- a/cypress/e2e/settings/discover-customization.cy.ts +++ b/cypress/e2e/settings/discover-customization.cy.ts @@ -239,4 +239,102 @@ describe('Discover Customization', () => { 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/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index fc58eb00f8..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,11 +204,7 @@ const DiscoverSliderEdit = ({ )}
@@ -210,9 +213,7 @@ const DiscoverSliderEdit = ({
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD || 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 de45f937e4..fd8c121221 100644 --- a/src/components/Discover/StudioSlider/index.tsx +++ b/src/components/Discover/StudioSlider/index.tsx @@ -1,92 +1,43 @@ 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 = () => { ( + isEmpty={studios.length === 0} + items={studios.map((studio) => ( ))} - emptyMessage="" + emptyMessage={intl.formatMessage(messages.emptyStudios)} /> ); diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 1ec6c29246..ec3df5d4cb 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -30,6 +30,7 @@ import { 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'; @@ -53,6 +54,8 @@ const messages = defineMessages('components.Discover', { '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', @@ -60,7 +63,7 @@ const messages = defineMessages('components.Discover', { // 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 list) are not thrown away. +// networks and studios lists) are not thrown away. const mergeRefreshedSliders = ( current: Partial[], refreshed: DiscoverSlider[] @@ -118,6 +121,19 @@ 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(); @@ -139,7 +155,8 @@ const Discover = () => { setIsEditing(false); mutate(); } catch (error) { - // A 400 carries an error code when the networks list was rejected + // 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 @@ -153,6 +170,12 @@ const Discover = () => { }); } 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, { @@ -318,7 +341,13 @@ const Discover = () => { ); break; case DiscoverSliderType.STUDIOS: - sliderComponent = ; + sliderComponent = ( + updateSliderData(slider.id, data)} + /> + ); break; case DiscoverSliderType.POPULAR_TV: sliderComponent = ( @@ -349,15 +378,7 @@ const Discover = () => { - setSliders((currentSliders) => - currentSliders.map((currentSlider) => - currentSlider.id === slider.id - ? { ...currentSlider, data } - : currentSlider - ) - ) - } + onChange={(data) => updateSliderData(slider.id, data)} /> ); break; 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 b19c0a5d74..5fff82dc60 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -123,6 +123,20 @@ "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", @@ -131,6 +145,7 @@ "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", @@ -156,6 +171,7 @@ "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", From c70bb465a350635f94b54a690abc1fe0b9ef8832 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Sat, 31 Oct 2026 23:51:14 -0600 Subject: [PATCH 7/7] style(prettier): format migration files --- .../postgres/1789300000000-AlterDiscoverSliderDataToText.ts | 4 +--- .../sqlite/1789300000000-AlterDiscoverSliderDataToText.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts b/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts index 2856c4e4a9..496445f916 100644 --- a/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts +++ b/server/migration/postgres/1789300000000-AlterDiscoverSliderDataToText.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AlterDiscoverSliderDataToText1789300000000 - implements MigrationInterface -{ +export class AlterDiscoverSliderDataToText1789300000000 implements MigrationInterface { name = 'AlterDiscoverSliderDataToText1789300000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts b/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts index 76eb69739b..22698662bf 100644 --- a/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts +++ b/server/migration/sqlite/1789300000000-AlterDiscoverSliderDataToText.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AlterDiscoverSliderDataToText1789300000000 - implements MigrationInterface -{ +export class AlterDiscoverSliderDataToText1789300000000 implements MigrationInterface { name = 'AlterDiscoverSliderDataToText1789300000000'; public async up(queryRunner: QueryRunner): Promise {