fix: allow proper block shuffle in custom shows (#1400)

This commit is contained in:
Christian Benincasa
2026-02-17 13:31:02 -05:00
committed by GitHub
parent 6cf5ee29dd
commit 35826a0625
7 changed files with 622 additions and 119 deletions

View File

@@ -0,0 +1,479 @@
import { faker } from '@faker-js/faker';
import { tag } from '@tunarr/types';
import dayjs from 'dayjs';
import tmp from 'tmp-promise';
import { v4 } from 'uuid';
import { test as baseTest, describe, expect } from 'vitest';
import { bootstrapTunarr } from '../bootstrap.ts';
import { setGlobalOptionsUnchecked } from '../globals.ts';
import { LoggerFactory } from '../util/logging/LoggerFactory.ts';
import { CustomShowDB } from './CustomShowDB.ts';
import { DBAccess } from './DBAccess.ts';
import { ProgramDB } from './ProgramDB.ts';
import { CustomShow } from './schema/CustomShow.ts';
import { CustomShowContent } from './schema/CustomShowContent.ts';
import type { NewCustomShowContent } from './schema/CustomShowContent.ts';
import { DrizzleDBAccess } from './schema/index.ts';
import { Program } from './schema/Program.ts';
import type { NewProgramDao } from './schema/Program.ts';
type Fixture = {
db: string;
customShowDb: CustomShowDB;
drizzle: DrizzleDBAccess;
};
const test = baseTest.extend<Fixture>({
db: async ({}, use) => {
const dbResult = await tmp.dir({ unsafeCleanup: true });
const opts = setGlobalOptionsUnchecked({
database: dbResult.path,
log_level: 'debug',
verbose: 0,
});
await bootstrapTunarr(opts);
await use(dbResult.path);
const dbPath = `${dbResult.path}/db.db`;
await DBAccess.instance.closeConnection(dbPath);
await dbResult.cleanup();
},
customShowDb: async ({ db: _ }, use) => {
const dbAccess = DBAccess.instance;
const logger = LoggerFactory.child({ className: 'ProgramDB' });
const mockTaskFactory = () => ({ enqueue: async () => {} }) as any;
const programDb = new ProgramDB(
logger,
mockTaskFactory,
mockTaskFactory,
dbAccess.db!,
() => ({}) as any,
dbAccess.drizzle!,
);
const customShowDb = new CustomShowDB(
programDb,
dbAccess.db!,
dbAccess.drizzle!,
);
await use(customShowDb);
},
drizzle: async ({ db: _ }, use) => {
await use(DBAccess.instance.drizzle!);
},
});
function createProgram(overrides?: Partial<NewProgramDao>): NewProgramDao {
const now = +dayjs();
return {
uuid: v4(),
canonicalId: v4(),
createdAt: now,
updatedAt: now,
albumName: null,
albumUuid: null,
artistName: null,
artistUuid: null,
duration: faker.number.int({ min: 60000, max: 7200000 }),
episode: null,
episodeIcon: null,
externalKey: faker.string.alphanumeric(10),
externalSourceId: tag(faker.string.alphanumeric(8)),
mediaSourceId: undefined,
libraryId: null,
localMediaFolderId: null,
localMediaSourcePathId: null,
filePath: null,
grandparentExternalKey: null,
icon: null,
originalAirDate: null,
parentExternalKey: null,
plexFilePath: null,
plexRatingKey: null,
rating: null,
seasonIcon: null,
seasonNumber: null,
seasonUuid: null,
showIcon: null,
showTitle: null,
sourceType: 'plex',
summary: null,
plot: null,
tagline: null,
title: faker.word.words(3),
tvShowUuid: null,
type: 'movie',
year: faker.date.past().getFullYear(),
state: 'ok',
...overrides,
};
}
async function insertProgram(drizzle: DrizzleDBAccess, program: NewProgramDao) {
await drizzle.insert(Program).values(program);
return program;
}
async function createCustomShow(
drizzle: DrizzleDBAccess,
name?: string,
): Promise<typeof CustomShow.$inferSelect> {
const now = +dayjs();
const show = {
uuid: v4(),
createdAt: now,
updatedAt: now,
name: name ?? faker.word.words(2),
};
await drizzle.insert(CustomShow).values(show);
return show;
}
async function insertCustomShowContent(
drizzle: DrizzleDBAccess,
rows: NewCustomShowContent[],
) {
if (rows.length > 0) {
await drizzle.insert(CustomShowContent).values(rows);
}
}
describe('CustomShowDB', () => {
describe('duplicate programs in custom shows', () => {
test('should allow the same program to appear multiple times with different indexes', async ({
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle);
const rows: NewCustomShowContent[] = [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 1 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 2 },
];
// This should NOT throw — the new composite PK includes index
await expect(
insertCustomShowContent(drizzle, rows),
).resolves.not.toThrow();
const content = await drizzle.query.customShowContent.findMany({
where: (fields, { eq }) => eq(fields.customShowUuid, show.uuid),
orderBy: (fields, { asc }) => asc(fields.index),
});
expect(content).toHaveLength(3);
expect(content.map((c) => c.index)).toEqual([0, 1, 2]);
expect(content.every((c) => c.contentUuid === program.uuid)).toBe(true);
});
test('should still enforce uniqueness on (contentUuid, customShowUuid, index) triple', async ({
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle);
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
]);
// Inserting the same (show, program, index) should fail
await expect(
insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
]),
).rejects.toThrow();
});
test('should allow different programs at the same index in different custom shows', async ({
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show1 = await createCustomShow(drizzle, 'Show 1');
const show2 = await createCustomShow(drizzle, 'Show 2');
await expect(
insertCustomShowContent(drizzle, [
{ customShowUuid: show1.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show2.uuid, contentUuid: program.uuid, index: 0 },
]),
).resolves.not.toThrow();
});
});
describe('getShowProgramsOrm', () => {
test('should return programs in index order', async ({
customShowDb,
drizzle,
}) => {
const programA = await insertProgram(drizzle, createProgram({ title: 'Program A' }));
const programB = await insertProgram(drizzle, createProgram({ title: 'Program B' }));
const programC = await insertProgram(drizzle, createProgram({ title: 'Program C' }));
const show = await createCustomShow(drizzle);
// Insert in non-sequential order to verify sorting
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: programC.uuid, index: 2 },
{ customShowUuid: show.uuid, contentUuid: programA.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: programB.uuid, index: 1 },
]);
const programs = await customShowDb.getShowProgramsOrm(show.uuid);
expect(programs).toHaveLength(3);
expect(programs[0]!.title).toBe('Program A');
expect(programs[1]!.title).toBe('Program B');
expect(programs[2]!.title).toBe('Program C');
});
test('should return duplicate programs preserving order', async ({
customShowDb,
drizzle,
}) => {
const programA = await insertProgram(drizzle, createProgram({ title: 'Repeat Me' }));
const programB = await insertProgram(drizzle, createProgram({ title: 'Other' }));
const show = await createCustomShow(drizzle);
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: programA.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: programB.uuid, index: 1 },
{ customShowUuid: show.uuid, contentUuid: programA.uuid, index: 2 },
]);
const programs = await customShowDb.getShowProgramsOrm(show.uuid);
expect(programs).toHaveLength(3);
expect(programs[0]!.title).toBe('Repeat Me');
expect(programs[1]!.title).toBe('Other');
expect(programs[2]!.title).toBe('Repeat Me');
});
test('should return empty array for nonexistent show', async ({
customShowDb,
}) => {
const programs = await customShowDb.getShowProgramsOrm(v4());
expect(programs).toHaveLength(0);
});
});
describe('getShow', () => {
test('should return show with program data', async ({
customShowDb,
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle);
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 1 },
]);
const result = await customShowDb.getShow(show.uuid);
expect(result).toBeDefined();
expect(result!.uuid).toBe(show.uuid);
expect(result!.name).toBe(show.name);
});
test('should return undefined for nonexistent show', async ({
customShowDb,
}) => {
const result = await customShowDb.getShow(v4());
expect(result).toBeUndefined();
});
});
describe('deleteShow', () => {
test('should delete a show and its content', async ({
customShowDb,
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle);
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 1 },
]);
const deleted = await customShowDb.deleteShow(show.uuid);
expect(deleted).toBe(true);
// Verify show is gone
const result = await customShowDb.getShow(show.uuid);
expect(result).toBeUndefined();
// Verify content is gone
const content = await drizzle.query.customShowContent.findMany({
where: (fields, { eq }) => eq(fields.customShowUuid, show.uuid),
});
expect(content).toHaveLength(0);
});
test('should return false for nonexistent show', async ({
customShowDb,
}) => {
const deleted = await customShowDb.deleteShow(v4());
expect(deleted).toBe(false);
});
});
describe('getAllShowsInfo', () => {
test('should return correct content count with duplicate programs', async ({
customShowDb,
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle, 'Duplicates Show');
// Same program at 3 different indexes
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 1 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 2 },
]);
const shows = await customShowDb.getAllShowsInfo();
const found = shows.find((s) => s.id === show.uuid);
expect(found).toBeDefined();
// With the .distinct() removed, count now correctly reflects total entries
expect(found!.count).toBe(3);
});
test('should return multiple shows with correct counts', async ({
customShowDb,
drizzle,
}) => {
const programA = await insertProgram(drizzle, createProgram());
const programB = await insertProgram(drizzle, createProgram());
const show1 = await createCustomShow(drizzle, 'Show One');
const show2 = await createCustomShow(drizzle, 'Show Two');
await insertCustomShowContent(drizzle, [
{ customShowUuid: show1.uuid, contentUuid: programA.uuid, index: 0 },
{ customShowUuid: show1.uuid, contentUuid: programB.uuid, index: 1 },
]);
await insertCustomShowContent(drizzle, [
{ customShowUuid: show2.uuid, contentUuid: programA.uuid, index: 0 },
]);
const shows = await customShowDb.getAllShowsInfo();
const found1 = shows.find((s) => s.id === show1.uuid);
const found2 = shows.find((s) => s.id === show2.uuid);
expect(found1).toBeDefined();
expect(found1!.count).toBe(2);
expect(found2).toBeDefined();
expect(found2!.count).toBe(1);
});
test('should calculate total duration across duplicate entries', async ({
customShowDb,
drizzle,
}) => {
const duration = 120000; // 2 minutes
const program = await insertProgram(
drizzle,
createProgram({ duration }),
);
const show = await createCustomShow(drizzle, 'Duration Test');
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 1 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 2 },
]);
const shows = await customShowDb.getAllShowsInfo();
const found = shows.find((s) => s.id === show.uuid);
expect(found).toBeDefined();
// totalDuration uses a correlated subquery per row, so with 3 content
// rows pointing to the same program, we get 3 * duration
expect(found!.totalDuration).toBe(duration * 3);
});
});
describe('getShows', () => {
test('should return shows with correct content counts via Drizzle path', async ({
customShowDb,
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle, 'Drizzle Show');
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 1 },
]);
const shows = await customShowDb.getShows([show.uuid]);
expect(shows).toHaveLength(1);
// The Drizzle path uses result.content.length, which correctly counts all entries
expect(shows[0]!.contentCount).toBe(2);
});
test('should return empty array for empty input', async ({
customShowDb,
}) => {
const shows = await customShowDb.getShows([]);
expect(shows).toHaveLength(0);
});
});
describe('cascade deletes', () => {
test('should delete custom show content when a program is deleted', async ({
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle);
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 1 },
]);
// Delete the program — content should cascade
await drizzle.delete(Program).where(
// Using drizzle eq
(await import('drizzle-orm')).eq(Program.uuid, program.uuid),
);
const content = await drizzle.query.customShowContent.findMany({
where: (fields, { eq }) => eq(fields.customShowUuid, show.uuid),
});
expect(content).toHaveLength(0);
});
test('should delete custom show content when a custom show is deleted', async ({
drizzle,
}) => {
const program = await insertProgram(drizzle, createProgram());
const show = await createCustomShow(drizzle);
await insertCustomShowContent(drizzle, [
{ customShowUuid: show.uuid, contentUuid: program.uuid, index: 0 },
]);
const { eq } = await import('drizzle-orm');
await drizzle.delete(CustomShow).where(eq(CustomShow.uuid, show.uuid));
const content = await drizzle.query.customShowContent.findMany({
where: (fields, ops) => ops.eq(fields.customShowUuid, show.uuid),
});
expect(content).toHaveLength(0);
});
});
});

View File

@@ -1,5 +1,12 @@
import { KEYS } from '@/types/inject.js';
import { isNonEmptyString, programExternalIdString } from '@/util/index.js';
import { isNonEmptyString } from '@/util/index.js';
import { createExternalId } from '@tunarr/shared';
import {
ContentProgram,
isContentProgram,
isCustomProgram,
tag,
} from '@tunarr/types';
import {
CreateCustomShowRequest,
UpdateCustomShowRequest,
@@ -7,10 +14,9 @@ import {
import dayjs from 'dayjs';
import { inject, injectable } from 'inversify';
import { Kysely } from 'kysely';
import { chunk, filter, isNil, map } from 'lodash-es';
import { chunk, isNil, orderBy } from 'lodash-es';
import { v4 } from 'uuid';
import { ProgramDB } from './ProgramDB.ts';
import { createPendingProgramIndexMap } from './programHelpers.ts';
import {
AllProgramJoins,
withCustomShowPrograms,
@@ -63,23 +69,6 @@ export class CustomShowDB {
contentCount: result.content.length,
}));
});
return this.db
.selectFrom('customShow')
.where('customShow.uuid', 'in', ids)
.innerJoin(
'customShowContent',
'customShowContent.customShowUuid',
'customShow.uuid',
)
.selectAll('customShow')
.select((eb) =>
eb.fn
.count<number>('customShowContent.contentUuid')
.distinct()
.as('contentCount'),
)
.execute();
}
async getShowPrograms(id: string): Promise<ProgramWithRelations[]> {
@@ -118,62 +107,15 @@ export class CustomShowDB {
return null;
}
if (updateRequest.programs) {
const programIndexById = createPendingProgramIndexMap(
updateRequest.programs,
);
const persisted = filter(
updateRequest.programs,
(p) => p.persisted && isNonEmptyString(p.id),
);
const upsertedPrograms = await this.programDB.upsertContentPrograms(
updateRequest.programs,
);
const persistedCustomShowContent = map(
persisted,
(p) =>
({
customShowUuid: show.uuid,
contentUuid: p.id!,
index: programIndexById[p.id!]!,
}) satisfies NewCustomShowContent,
);
const newCustomShowContent = map(
upsertedPrograms,
(p) =>
({
customShowUuid: show.uuid,
contentUuid: p.uuid,
index: programIndexById[programExternalIdString(p)]!,
}) satisfies NewCustomShowContent,
);
await this.db.transaction().execute(async (tx) => {
await tx
.deleteFrom('customShowContent')
.where('customShowContent.customShowUuid', '=', show.uuid)
.execute();
await Promise.all(
chunk(
[...persistedCustomShowContent, ...newCustomShowContent],
1_000,
).map((csc) =>
tx.insertInto('customShowContent').values(csc).execute(),
),
);
});
if (updateRequest.programs && updateRequest.programs.length > 0) {
await this.upsertCustomShowContent(show.uuid, updateRequest.programs);
}
if (updateRequest.name) {
await this.db
.updateTable('customShow')
.where('uuid', '=', show.uuid)
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
// .limit(1)
.limit(1)
.set({ name: updateRequest.name })
.execute();
}
@@ -190,45 +132,9 @@ export class CustomShowDB {
name: createRequest.name,
} satisfies NewCustomShow;
const programIndexById = createPendingProgramIndexMap(
createRequest.programs,
);
const persisted = filter(createRequest.programs, (p) => p.persisted);
const upsertedPrograms = await this.programDB.upsertContentPrograms(
createRequest.programs,
);
await this.db.insertInto('customShow').values(show).execute();
const persistedCustomShowContent = map(
persisted,
(p) =>
({
customShowUuid: show.uuid,
contentUuid: p.id!,
index: programIndexById[p.id!]!,
}) satisfies NewCustomShowContent,
);
const newCustomShowContent = map(
upsertedPrograms,
(p) =>
({
customShowUuid: show.uuid,
contentUuid: p.uuid,
index: programIndexById[programExternalIdString(p)]!,
}) satisfies NewCustomShowContent,
);
await Promise.all(
chunk(
[...persistedCustomShowContent, ...newCustomShowContent],
1_000,
).map((csc) =>
this.db.insertInto('customShowContent').values(csc).execute(),
),
);
await this.upsertCustomShowContent(show.uuid, createRequest.programs);
return show.uuid;
}
@@ -278,10 +184,7 @@ export class CustomShowDB {
)
.groupBy('customShow.uuid')
.select((eb) => [
eb.fn
.count<number>('customShowContent.contentUuid')
.distinct()
.as('contentCount'),
eb.fn.count<number>('customShowContent.contentUuid').as('contentCount'),
eb.fn
.sum<number>(
eb
@@ -299,4 +202,85 @@ export class CustomShowDB {
totalDuration: f.totalDuration,
}));
}
private async upsertCustomShowContent(
customShowId: string,
programs: ContentProgram[],
) {
if (programs.length === 0) {
return;
}
const newProgramIndexesById: Record<string, number[]> = {};
for (let i = 0; i < programs.length; i++) {
const program = programs[i]!;
if (
(program.persisted ||
isCustomProgram(program) ||
program.externalSourceType === 'local') &&
isNonEmptyString(program.id)
) {
newProgramIndexesById[program.id] ??= [];
newProgramIndexesById[program.id]!.push(i);
} else if (
isContentProgram(program) &&
program.externalSourceType !== 'local'
) {
const key = createExternalId(
program.externalSourceType,
tag(program.externalSourceId),
program.externalKey,
);
newProgramIndexesById[key] ??= [];
newProgramIndexesById[key].push(i);
}
}
const upsertedPrograms =
await this.programDB.upsertContentPrograms(programs);
const allNewCustomContent = orderBy(
upsertedPrograms.flatMap((program) => {
let indexes = newProgramIndexesById[program.uuid];
if (!indexes && program.sourceType !== 'local') {
const externalId = createExternalId(
program.sourceType,
program.mediaSourceId,
program.externalKey,
);
indexes = newProgramIndexesById[externalId];
}
if (!indexes) {
return [];
}
return indexes.map(
(index) =>
({
customShowUuid: customShowId,
contentUuid: program.uuid,
index,
}) satisfies NewCustomShowContent,
);
}),
(csc) => csc.index,
'asc',
).map((csc, idx) => {
csc.index = idx;
return csc;
});
await this.db.transaction().execute(async (tx) => {
if (allNewCustomContent.length > 0) {
await tx
.deleteFrom('customShowContent')
.where('customShowContent.customShowUuid', '=', customShowId)
.execute();
for (const contentChunk of chunk(allNewCustomContent, 1_000)) {
await tx
.insertInto('customShowContent')
.values(contentChunk)
.execute();
}
}
});
}
}

View File

@@ -1,5 +1,6 @@
import { relations } from 'drizzle-orm';
import {
foreignKey,
integer,
primaryKey,
sqliteTable,
@@ -22,7 +23,19 @@ export const CustomShowContent = sqliteTable(
index: integer().notNull(),
},
(table) => [
primaryKey({ columns: [table.contentUuid, table.customShowUuid] }),
primaryKey({
columns: [table.contentUuid, table.customShowUuid, table.index],
}),
foreignKey({
name: 'custom_show_content_content_uuid_foreign',
columns: [table.contentUuid],
foreignColumns: [Program.uuid],
}).onDelete('cascade'),
foreignKey({
name: 'custom_show_content_custom_show_uuid_foreign',
columns: [table.customShowUuid],
foreignColumns: [CustomShow.uuid],
}).onDelete('cascade'),
],
);

View File

@@ -52,6 +52,7 @@ import Migration1763749592_AddProgramState from './db/Migration1763749592_AddPro
import Migration1764022266_AddCreditGroupingIndex from './db/Migration1764022266_AddCreditGroupingIndex.ts';
import Migration1764022464_AddArtworkIndexes from './db/Migration1764022464_AddArtworkIndexes.ts';
import Migration1767300603_AddExternalCollections from './db/Migration1767300603_AddExternalCollections.ts';
import Migration1771271020_FixCustomShowContentKey from './db/Migration1771271020_FixCustomShowContentKey.ts';
import { makeKyselyMigrationFromSqlFile } from './db/util.ts';
export const LegacyMigrationNameToNewMigrationName = [
@@ -199,6 +200,7 @@ export class DirectMigrationProvider implements MigrationProvider {
migration1770236998: makeKyselyMigrationFromSqlFile(
'./sql/0041_easy_firebird.sql',
),
migration1771271020: Migration1771271020_FixCustomShowContentKey,
},
wrapWithTransaction,
),

View File

@@ -0,0 +1,29 @@
import { CompiledQuery } from 'kysely';
import type { TunarrDatabaseMigration } from '../DirectMigrationProvider.ts';
export default {
fullCopy: true,
async up(db) {
const statements = [
'PRAGMA foreign_keys = OFF',
'ALTER TABLE `custom_show_content` RENAME TO `old_custom_show_content`',
`
CREATE TABLE IF NOT EXISTS "custom_show_content" (
"custom_show_uuid" text not null,
"content_uuid" text not null,
"index" integer not null,
constraint "custom_show_content_custom_show_uuid_foreign" foreign key ("custom_show_uuid") references "custom_show" ("uuid") on delete cascade on update cascade,
constraint "custom_show_content_content_uuid_foreign" foreign key ("content_uuid") references "program" ("uuid") on delete cascade on update cascade,
constraint "primary_key" primary key ("custom_show_uuid", "content_uuid", "index")
)
`,
'INSERT INTO `custom_show_content`(custom_show_uuid, content_uuid, "index") SELECT custom_show_uuid, content_uuid, "index" FROM `old_custom_show_content`',
'DROP TABLE `old_custom_show_content`',
'PRAGMA foreign_keys = ON',
];
for (const statement of statements) {
await db.executeQuery(CompiledQuery.raw(statement.trim()));
}
},
} satisfies TunarrDatabaseMigration;

View File

@@ -83,9 +83,7 @@ export const BatchLookupExternalProgrammingSchema = z.object({
export const CreateCustomShowRequestSchema = z.object({
name: z.string(),
programs: z.array(
z.discriminatedUnion('type', [ContentProgramSchema, CustomProgramSchema]),
),
programs: z.array(ContentProgramSchema),
});
export type CreateCustomShowRequest = z.infer<

View File

@@ -1,7 +1,6 @@
import { queryClient } from '@/queryClient';
import useStore from '@/store';
import {
clearCurrentCustomShow,
moveProgramInCustomShow,
resetCustomShowProgramming,
updateCurrentCustomShow,
@@ -77,13 +76,14 @@ export function EditCustomShowsForm({
data: CustomShowForm & { programs: UICustomShowProgram[] },
) => {
if (isNew) {
return createCustomShow({ body: data });
return createCustomShow({ body: data, throwOnError: true });
} else {
return putApiCustomShowsById({
path: {
id: customShow.id,
},
body: data,
throwOnError: true,
});
}
},
@@ -92,8 +92,6 @@ export function EditCustomShowsForm({
queryKey: getApiCustomShowsQueryKey(),
exact: false,
});
clearCurrentCustomShow();
navigate({ to: '/library/custom-shows' }).catch(console.warn);
},
});