mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
fix: allow proper block shuffle in custom shows (#1400)
This commit is contained in:
committed by
GitHub
parent
6cf5ee29dd
commit
35826a0625
479
server/src/db/CustomShowDB.test.ts
Normal file
479
server/src/db/CustomShowDB.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
@@ -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<
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user