Skip to content

Commit

Permalink
refactor: mint programs on the frontend
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbenincasa committed Jan 10, 2025
1 parent de5cd1d commit 4e9887c
Show file tree
Hide file tree
Showing 36 changed files with 857 additions and 638 deletions.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 30 additions & 105 deletions server/src/db/ProgramDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,13 @@ import {
ContentProgram,
isContentProgram,
} from '@tunarr/types';
import { JellyfinItem } from '@tunarr/types/jellyfin';
import { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
import { ContentProgramOriginalProgram } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { CaseWhenBuilder } from 'kysely';
import {
chunk,
concat,
difference,
filter,
find,
first,
flatMap,
forEach,
Expand All @@ -46,7 +42,6 @@ import {
values,
} from 'lodash-es';
import { MarkOptional, MarkRequired } from 'ts-essentials';
import { P, match } from 'ts-pattern';
import { v4 } from 'uuid';
import {
flatMapAsyncSeq,
Expand Down Expand Up @@ -96,13 +91,13 @@ import {
import { NewProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
import { DB } from './schema/db.ts';
import {
ProgramDaoWithRelations,
ProgramGroupingWithExternalIds,
ProgramWithRelations,
} from './schema/derivedTypes.js';

type ValidatedContentProgram = MarkRequired<
ContentProgram,
'originalProgram' | 'externalSourceName' | 'externalSourceType'
'externalSourceName' | 'externalSourceType'
>;

type MintedRawProgramInfo = {
Expand All @@ -111,9 +106,12 @@ type MintedRawProgramInfo = {
apiProgram: ValidatedContentProgram;
};

type NonMovieOriginalProgram =
| { sourceType: 'plex'; program: PlexEpisode | PlexMusicTrack }
| { sourceType: 'jellyfin'; program: JellyfinItem };
type ContentProgramWithHierarchy = Omit<
MarkRequired<ContentProgram, 'grandparent' | 'parent'>,
'subtype'
> & {
subtype: 'episode' | 'track';
};

type ProgramRelationCaseBuilder = CaseWhenBuilder<
DB,
Expand Down Expand Up @@ -173,8 +171,8 @@ export class ProgramDB {
async getProgramsByIds(
ids: string[],
batchSize: number = 500,
): Promise<ProgramDaoWithRelations[]> {
const results: ProgramDaoWithRelations[] = [];
): Promise<ProgramWithRelations[]> {
const results: ProgramWithRelations[] = [];
for (const idChunk of chunk(ids, batchSize)) {
const res = await getDatabase()
.selectFrom('program')
Expand Down Expand Up @@ -244,7 +242,7 @@ export class ProgramDB {
const converter = new ProgramConverter();

const allIds = [...ids];
const programsByExternalIds: ProgramDaoWithRelations[] = [];
const programsByExternalIds: ProgramWithRelations[] = [];
for (const idChunk of chunk(allIds, 200)) {
programsByExternalIds.push(
...(await getDatabase()
Expand Down Expand Up @@ -441,9 +439,9 @@ export class ProgramDB {
const [contentPrograms, invalidPrograms] = partition(
uniqBy(filter(nonPersisted, isContentProgram), (p) => p.uniqueId),
(p): p is ValidatedContentProgram =>
!isNil(p.externalSourceType) &&
!isNil(p.externalSourceName) &&
!isNil(p.originalProgram) &&
isNonEmptyString(p.externalSourceType) &&
isNonEmptyString(p.externalSourceName) &&
isNonEmptyString(p.externalKey) &&
p.duration > 0,
);

Expand Down Expand Up @@ -521,44 +519,15 @@ export class ProgramDB {
// >,
// );

// const existingPrograms = flatten(
// await mapAsyncSeq(chunk(values(pMap), 500), (items) => {
// return directDbAccess()
// .selectFrom('programExternalId')
// .where(({ or, eb }) => {
// const clauses = map(items, (item) =>
// eb('programExternalId.sourceType', '=', item.type).and(
// 'programExternalId.externalKey',
// '=',
// item.id,
// ),
// );
// return or(clauses);
// })
// .selectAll('programExternalId')
// .select((eb) =>
// jsonArrayFrom(
// eb
// .selectFrom('program')
// .whereRef('programExternalId.programUuid', '=', 'program.uuid')
// .select(AllProgramFields),
// ).as('program'),
// )
// .groupBy('programExternalId.programUuid')
// .execute();
// }),
// );
// console.log('results!!!!', existingPrograms);

// TODO: handle custom shows
const programsToPersist: MintedRawProgramInfo[] = map(
contentPrograms,
(p) => {
const program = minter.mint(p.externalSourceName, p.originalProgram);
const program = minter.contentProgramDtoToDao(p);
const externalIds = minter.mintExternalIds(
p.externalSourceName,
program.uuid,
p.originalProgram,
p,
);
return { program, externalIds, apiProgram: p };
},
Expand All @@ -571,9 +540,6 @@ export class ProgramDB {

this.logger.debug('Upserting %d programs', programsToPersist.length);

// NOTE: upsert will not handle any relations. That's why we need to do
// these manually below. Relations all have IDs generated application side
// so we can't get proper diffing on 1:M Program:X, etc.
// TODO: The way we deal with uniqueness right now makes a Program entity
// exist 1:1 with its "external" entity, i.e. the same logical movie will
// have duplicate entries in the DB across different servers and sources.
Expand Down Expand Up @@ -660,17 +626,6 @@ export class ProgramDB {
),
),
);
// DatabaseTaskQueue.addFunc('UpsertExternalIds', () => {
// return this.timer.timeAsync(
// `background external ID upsert (${backgroundExternalIds.length} ids)`,
// () => upsertRawProgramExternalIds(backgroundExternalIds),
// );
// }).catch((e) => {
// this.logger.error(
// e,
// 'Error saving non-essential external IDs. A fixer will run for these',
// );
// });
});

const end = performance.now();
Expand Down Expand Up @@ -725,7 +680,13 @@ export class ProgramDB {
const grandparentRatingKeyToProgramId: Record<string, Set<string>> = {};
const parentRatingKeyToProgramId: Record<string, Set<string>> = {};

const relevantPrograms = seq.collect(upsertedPrograms, (program) => {
const relevantPrograms: [
RawProgram,
ContentProgramWithHierarchy & {
grandparentKey: string;
parentKey: string;
},
][] = seq.collect(upsertedPrograms, (program) => {
if (program.type === ProgramType.Movie) {
return;
}
Expand All @@ -735,52 +696,27 @@ export class ProgramDB {
return;
}

const originalProgram = info.apiProgram.originalProgram;

if (originalProgram.sourceType !== mediaSourceType) {
return;
}

if (isMovieMediaItem(originalProgram)) {
if (info.apiProgram.subtype === 'movie') {
return;
}

const [grandparentKey, parentKey] = match(originalProgram)
.with(
{
sourceType: 'plex',
program: { type: P.union('episode', 'track') },
},
({ program: ep }) =>
[ep.grandparentRatingKey, ep.parentRatingKey] as const,
)
.with(
{ sourceType: 'jellyfin', program: { Type: 'Episode' } },
({ program: ep }) =>
[ep.SeriesId, ep.ParentId ?? ep.SeasonId] as const,
)
.with(
{ sourceType: 'jellyfin', program: { Type: 'Audio' } },
({ program: ep }) =>
[
find(ep.AlbumArtists, { Name: ep.AlbumArtist })?.Id,
ep.ParentId ?? ep.AlbumId,
] as const,
)
.otherwise(() => [null, null] as const);
const [grandparentKey, parentKey] = [
info.apiProgram.grandparent?.externalKey,
info.apiProgram.parent?.externalKey,
];

if (!grandparentKey || !parentKey) {
this.logger.warn(
'Unexpected null/empty parent keys: %O',
originalProgram,
info.apiProgram,
);
return;
}

return [
program,
{
...(originalProgram as NonMovieOriginalProgram),
...(info.apiProgram as ContentProgramWithHierarchy),
grandparentKey,
parentKey,
},
Expand Down Expand Up @@ -1041,10 +977,6 @@ export class ProgramDB {
getDatabase()
.transaction()
.execute(async (tx) => {
// const allProgramIds = flatMap(values(updatesByType), (set) => [
// ...set,
// ]);

// For each program, we produce 3 SQL variables: when = ?, then = ?, and uuid in [?].
// We have to chunk by type in order to ensure we don't go over the variable limit
const tvShowIdUpdates = [
Expand Down Expand Up @@ -1214,10 +1146,3 @@ export class ProgramDB {
});
}
}

function isMovieMediaItem(item: ContentProgramOriginalProgram): boolean {
return match(item)
.with({ sourceType: 'plex', program: { type: 'movie' } }, () => true)
.with({ sourceType: 'jellyfin', program: { Type: 'Movie' } }, () => true)
.otherwise(() => false);
}
57 changes: 39 additions & 18 deletions server/src/db/converters/ProgramConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
import {
ChannelWithPrograms,
ChannelWithRelations,
ProgramDaoWithRelations,
ProgramWithRelations,
} from '../schema/derivedTypes.js';

/**
Expand All @@ -46,7 +46,7 @@ export class ProgramConverter {
DeepPartial<ChannelWithRelations>,
'uuid' | 'number' | 'name'
>[], // TODO fix this up...
preMaterializedProgram?: ProgramDaoWithRelations,
preMaterializedProgram?: ProgramWithRelations,
): ChannelProgram | null;
lineupItemToChannelProgram(
channel: ChannelWithPrograms,
Expand All @@ -55,7 +55,7 @@ export class ProgramConverter {
DeepPartial<ChannelWithRelations>,
'uuid' | 'number' | 'name'
>[], // TODO fix this up...
preMaterializedProgram?: ProgramDaoWithRelations,
preMaterializedProgram?: ProgramWithRelations,
): ChannelProgram | null {
if (isOfflineItem(item)) {
return this.offlineLineupItemToProgram(channel, item);
Expand Down Expand Up @@ -90,7 +90,7 @@ export class ProgramConverter {
}

programDaoToContentProgram(
program: ProgramDaoWithRelations,
program: ProgramWithRelations,
externalIds: MinimalProgramExternalId[],
): ContentProgram {
let extraFields: Partial<ContentProgram> = {};
Expand All @@ -105,30 +105,47 @@ export class ProgramConverter {
program.tvSeason?.index, // ?? program.seasonNumber,
),
episodeNumber: nullToUndefined(program.episode),
episodeTitle: program.title,
title: nullToUndefined(program.tvShow?.title ?? program.showTitle),
title: program.title,
parent: {
id: nullToUndefined(program.tvSeason?.uuid ?? program.seasonUuid),
index: nullToUndefined(program.tvSeason?.index),
title: nullToUndefined(program.tvSeason?.title ?? program.showTitle),
year: nullToUndefined(program.tvSeason?.year),
},
grandparent: {
id: nullToUndefined(program.tvShow?.uuid ?? program.tvShowUuid),
index: nullToUndefined(program.tvShow?.index),
title: nullToUndefined(program.tvShow?.title),
// TODO:
// externalKey: nullToUndefined(program.tvSeason)
year: nullToUndefined(program.tvShow?.year),
},
index: nullToUndefined(program.episode),
parentIndex: nullToUndefined(program.tvSeason?.index),
grandparentIndex: nullToUndefined(program.tvShow?.index),
};
// if (isEmpty(extraFields.showId)) {
// this.logger.warn(
// 'Empty show UUID when converting program ID = %s. This may lead to broken frontend features. Please file a bug!',
// program.uuid,
// );
// }
} else if (program.type === ProgramType.Track.toString()) {
extraFields = {
albumName: nullToUndefined(program.trackAlbum?.title),
artistName: nullToUndefined(program.trackArtist?.title),
parent: {
id: nullToUndefined(program.trackAlbum?.uuid ?? program.albumUuid),
index: nullToUndefined(program.trackAlbum?.index),
title: nullToUndefined(
program.albumName ?? program.trackAlbum?.title,
),
year: nullToUndefined(program.trackAlbum?.year),
},
grandparent: {
id: nullToUndefined(program.trackArtist?.uuid ?? program.artistUuid),
index: nullToUndefined(program.trackArtist?.index),
title: nullToUndefined(program.trackArtist?.title),
// TODO:
// externalKey: nullToUndefined(program.trackArtist?.),
year: nullToUndefined(program.trackArtist?.year),
},
albumId: nullToUndefined(program.trackAlbum?.uuid ?? program.albumUuid),
artistId: nullToUndefined(
program.trackArtist?.uuid ?? program.artistUuid,
),
// HACK: Tracks save their index under the episode field
index: nullToUndefined(program.episode),
parentIndex: nullToUndefined(program.trackAlbum?.index),
grandparentIndex: nullToUndefined(program.trackArtist?.index),
};
}

Expand All @@ -145,6 +162,10 @@ export class ProgramConverter {
id: program.uuid,
subtype: program.type,
externalIds: seq.collect(externalIds, (eid) => this.toExternalId(eid)),
externalKey: program.externalKey,
externalSourceId: program.externalSourceId,
externalSourceName: program.externalSourceId,
externalSourceType: program.sourceType,
...omitBy(extraFields, isNil),
};
}
Expand Down
Loading

0 comments on commit 4e9887c

Please sign in to comment.