fix: ensure multi-season descendants are returned in proper season/episode order

This commit is contained in:
Christian Benincasa
2026-03-04 15:25:44 -05:00
parent 382b991805
commit e6b662f0ef
3 changed files with 327 additions and 5 deletions

View File

@@ -1782,6 +1782,324 @@ describe('ProgramDB', () => {
expect(descendants.length).toBeGreaterThan(0);
expect(descendants[0]?.uuid).toEqual(episode.program.uuid);
});
describe('getProgramGroupingDescendants ordering', () => {
function makeEpisode(
libraryId: string,
mediaSourceId: string,
showUuid: string,
seasonUuid: string,
seasonNumber: number,
episodeNumber: number,
): NewProgramWithRelations {
return {
program: createBaseProgram('episode', libraryId, 'local', {
mediaSourceId: tag<MediaSourceId>(mediaSourceId),
tvShowUuid: showUuid,
seasonUuid,
seasonNumber,
episode: episodeNumber,
}),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
versions: [],
subtitles: [],
tags: [],
};
}
test('returns episodes ordered by season then episode number', async ({
programDb,
drizzle,
}) => {
const library = await createTestMediaSourceLibrary(drizzle);
const showGrouping: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'show',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
const showResult = await programDb.upsertProgramGrouping(showGrouping);
const showUuid = showResult.entity.uuid;
// Create two seasons
const season1: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'season',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
showUuid,
index: 1,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
const season2: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'season',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
showUuid,
index: 2,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
const season1Result = await programDb.upsertProgramGrouping(season1);
const season2Result = await programDb.upsertProgramGrouping(season2);
// Insert in deliberately scrambled order: s2e1, s1e2, s1e1
await programDb.upsertPrograms([
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
season2Result.entity.uuid,
2,
1,
),
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
season1Result.entity.uuid,
1,
2,
),
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
season1Result.entity.uuid,
1,
1,
),
]);
const descendants = await programDb.getProgramGroupingDescendants(
showUuid,
'show',
);
expect(descendants).toHaveLength(3);
// s1e1, s1e2, s2e1
expect(descendants[0]?.seasonNumber).toBe(1);
expect(descendants[0]?.episode).toBe(1);
expect(descendants[1]?.seasonNumber).toBe(1);
expect(descendants[1]?.episode).toBe(2);
expect(descendants[2]?.seasonNumber).toBe(2);
expect(descendants[2]?.episode).toBe(1);
});
test('orders by season.index (grouping index) rather than seasonNumber fallback', async ({
programDb,
drizzle,
}) => {
const library = await createTestMediaSourceLibrary(drizzle);
const showGrouping: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'show',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
const showResult = await programDb.upsertProgramGrouping(showGrouping);
const showUuid = showResult.entity.uuid;
// Season index=1 but give it a higher seasonNumber to verify index wins
const season1: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'season',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
showUuid,
index: 1,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
// Season index=2
const season2: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'season',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
showUuid,
index: 2,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
const season1Result = await programDb.upsertProgramGrouping(season1);
const season2Result = await programDb.upsertProgramGrouping(season2);
// Insert s2e1 first, then s1e1 — result should still be s1e1, s2e1
await programDb.upsertPrograms([
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
season2Result.entity.uuid,
2,
1,
),
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
season1Result.entity.uuid,
1,
1,
),
]);
const descendants = await programDb.getProgramGroupingDescendants(
showUuid,
'show',
);
expect(descendants).toHaveLength(2);
expect(descendants[0]?.season?.index).toBe(1);
expect(descendants[1]?.season?.index).toBe(2);
});
test('returns episodes for a specific season in episode order', async ({
programDb,
drizzle,
}) => {
const library = await createTestMediaSourceLibrary(drizzle);
const showGrouping: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'show',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
const showResult = await programDb.upsertProgramGrouping(showGrouping);
const showUuid = showResult.entity.uuid;
const season: NewProgramGroupingWithRelations = {
programGrouping: createBaseProgramGrouping(
'season',
library.uuid,
'local',
{
mediaSourceId: library.mediaSourceId,
showUuid,
index: 1,
},
),
externalIds: [],
genres: [],
studios: [],
artwork: [],
credits: [],
tags: [],
};
const seasonResult = await programDb.upsertProgramGrouping(season);
const seasonUuid = seasonResult.entity.uuid;
// Insert episodes out of order: e3, e1, e2
await programDb.upsertPrograms([
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
seasonUuid,
1,
3,
),
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
seasonUuid,
1,
1,
),
makeEpisode(
library.uuid,
library.mediaSourceId,
showUuid,
seasonUuid,
1,
2,
),
]);
const descendants = await programDb.getProgramGroupingDescendants(
seasonUuid,
'season',
);
expect(descendants).toHaveLength(3);
expect(descendants[0]?.episode).toBe(1);
expect(descendants[1]?.episode).toBe(2);
expect(descendants[2]?.episode).toBe(3);
});
});
});
describe('Idempotency and Concurrent Operations', () => {

View File

@@ -76,6 +76,7 @@ import {
map,
mapValues,
omit,
orderBy,
partition,
reduce,
reject,
@@ -2501,7 +2502,7 @@ export class ProgramDB implements IProgramDB {
groupId: string,
groupTypeHint?: ProgramGroupingType,
): Promise<ProgramWithRelationsOrm[]> {
return this.drizzleDB.query.program.findMany({
const programs = await this.drizzleDB.query.program.findMany({
where: (fields, { or, eq }) => {
if (groupTypeHint) {
switch (groupTypeHint) {
@@ -2550,11 +2551,13 @@ export class ProgramDB implements IProgramDB {
: undefined,
externalIds: true,
},
orderBy: (fields, { asc }) => [
asc(fields.seasonNumber),
asc(fields.episode),
],
});
return orderBy(
programs,
[(p) => p.season?.index ?? p.seasonNumber ?? 1, (p) => p.episode ?? 1],
['asc', 'asc'],
);
}
async updateProgramsState(

View File

@@ -449,6 +449,7 @@ export class ProgramDaoMinter {
createdAt: now,
updatedAt: now,
canonicalId: episode.canonicalId,
seasonNumber: episode.season?.index,
episode: episode.episodeNumber,
state: 'ok',
} satisfies NewEpisodeProgram;