refactor: break up DB classes into focused repositories

This commit is contained in:
Christian Benincasa
2026-03-31 12:43:48 -04:00
parent 09ce8d4437
commit c95d8028a6
19 changed files with 6238 additions and 5202 deletions

View File

@@ -17,6 +17,11 @@ import type { IChannelDB } from './interfaces/IChannelDB.ts';
import { ChannelPrograms } from './schema/ChannelPrograms.ts';
import { Program } from './schema/Program.ts';
import { IProgramDB } from './interfaces/IProgramDB.ts';
import { BasicChannelRepository } from './channel/BasicChannelRepository.ts';
import { ChannelProgramRepository } from './channel/ChannelProgramRepository.ts';
import { LineupRepository } from './channel/LineupRepository.ts';
import { ChannelConfigRepository } from './channel/ChannelConfigRepository.ts';
import { MaterializeLineupCommand } from '../commands/MaterializeLineupCommand.ts';
type Fixture = {
db: string;
@@ -51,24 +56,51 @@ const test = baseTest.extend<Fixture>({
}
return task;
},
queueTask: async (task: any) => {
return { result: task };
},
}) as any;
const mockMaterializeLineupCommand = {
execute: async () => {},
execute: async () => ({}),
} as any;
const channelDb = new ChannelDB(
new ProgramConverter(
LoggerFactory.child({ className: ProgramConverter.name }),
dbAccess.db!,
),
mock<IProgramDB>(),
mock(CacheImageService),
dbAccess.db!, // Kysely instance
const fileSystemService = new FileSystemService(globalOptions());
const programConverter = new ProgramConverter(
LoggerFactory.child({ className: ProgramConverter.name }),
dbAccess.db!,
);
const lineupRepo = new LineupRepository(
dbAccess.db!,
dbAccess.drizzle!,
fileSystemService,
mockWorkerPoolFactory,
new FileSystemService(globalOptions()),
dbAccess.drizzle!, // Drizzle instance
mockMaterializeLineupCommand,
mock<IProgramDB>(),
programConverter,
);
const basicChannelRepo = new BasicChannelRepository(
dbAccess.db!,
dbAccess.drizzle!,
mock(CacheImageService),
lineupRepo,
);
const channelProgramRepo = new ChannelProgramRepository(
dbAccess.db!,
dbAccess.drizzle!,
);
const channelConfigRepo = new ChannelConfigRepository(dbAccess.db!);
const channelDb = new ChannelDB(
basicChannelRepo,
channelProgramRepo,
lineupRepo,
channelConfigRepo,
);
await use(channelDb);

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,14 @@ import { LoggerFactory } from '../util/logging/LoggerFactory.ts';
import { CustomShowDB } from './CustomShowDB.ts';
import { DBAccess } from './DBAccess.ts';
import { ProgramDB } from './ProgramDB.ts';
import { BasicProgramRepository } from './program/BasicProgramRepository.ts';
import { ProgramExternalIdRepository } from './program/ProgramExternalIdRepository.ts';
import { ProgramGroupingRepository } from './program/ProgramGroupingRepository.ts';
import { ProgramGroupingUpsertRepository } from './program/ProgramGroupingUpsertRepository.ts';
import { ProgramMetadataRepository } from './program/ProgramMetadataRepository.ts';
import { ProgramSearchRepository } from './program/ProgramSearchRepository.ts';
import { ProgramStateRepository } from './program/ProgramStateRepository.ts';
import { ProgramUpsertRepository } from './program/ProgramUpsertRepository.ts';
import type { MediaSourceId, MediaSourceName } from './schema/base.ts';
import { CustomShow } from './schema/CustomShow.ts';
import type { NewCustomShowContent } from './schema/CustomShowContent.ts';
@@ -122,13 +130,49 @@ const test = baseTest.extend<Fixture>({
},
});
const programDb = new ProgramDB(
const metadataRepo = new ProgramMetadataRepository(
dbAccess.getConnection(':memory:')!.drizzle!,
);
const externalIdRepo = new ProgramExternalIdRepository(
logger,
mockTaskFactory,
mockTaskFactory,
dbAccess.getKyselyDatabase(':memory:')!,
dbAccess.getConnection(':memory:')!.drizzle!,
);
const groupingUpsertRepo = new ProgramGroupingUpsertRepository(
dbAccess.getKyselyDatabase(':memory:')!,
dbAccess.getConnection(':memory:')!.drizzle!,
metadataRepo,
);
const upsertRepo = new ProgramUpsertRepository(
logger,
dbAccess.getKyselyDatabase(':memory:')!,
dbAccess.getConnection(':memory:')!.drizzle!,
mockTaskFactory,
mockTaskFactory,
mockMinterFactory as any,
dbAccess.getConnection(':memory:')?.drizzle!,
externalIdRepo,
metadataRepo,
groupingUpsertRepo,
);
const programDb = new ProgramDB(
new BasicProgramRepository(
dbAccess.getKyselyDatabase(':memory:')!,
dbAccess.getConnection(':memory:')!.drizzle!,
),
new ProgramGroupingRepository(
logger,
dbAccess.getKyselyDatabase(':memory:')!,
dbAccess.getConnection(':memory:')!.drizzle!,
),
externalIdRepo,
upsertRepo,
metadataRepo,
groupingUpsertRepo,
new ProgramSearchRepository(
dbAccess.getKyselyDatabase(':memory:')!,
dbAccess.getConnection(':memory:')!.drizzle!,
),
new ProgramStateRepository(dbAccess.getConnection(':memory:')!.drizzle!),
);
const customShowDb = new CustomShowDB(

View File

@@ -12,8 +12,38 @@ import { ProgramPlayHistoryDB } from './ProgramPlayHistoryDB.ts';
import { ProgramDaoMinter } from './converters/ProgramMinter.ts';
import type { DB } from './schema/db.ts';
import type { DrizzleDBAccess } from './schema/index.ts';
import { BasicProgramRepository } from './program/BasicProgramRepository.ts';
import { ProgramGroupingRepository } from './program/ProgramGroupingRepository.ts';
import { ProgramExternalIdRepository } from './program/ProgramExternalIdRepository.ts';
import { ProgramUpsertRepository } from './program/ProgramUpsertRepository.ts';
import { ProgramMetadataRepository } from './program/ProgramMetadataRepository.ts';
import { ProgramGroupingUpsertRepository } from './program/ProgramGroupingUpsertRepository.ts';
import { ProgramSearchRepository } from './program/ProgramSearchRepository.ts';
import { ProgramStateRepository } from './program/ProgramStateRepository.ts';
import { BasicChannelRepository } from './channel/BasicChannelRepository.ts';
import { ChannelProgramRepository } from './channel/ChannelProgramRepository.ts';
import { LineupRepository } from './channel/LineupRepository.ts';
import { ChannelConfigRepository } from './channel/ChannelConfigRepository.ts';
const DBModule = new ContainerModule((bind) => {
// ProgramDB sub-repositories (must be registered before ProgramDB itself)
bind(KEYS.BasicProgramRepository).to(BasicProgramRepository).inSingletonScope();
bind(KEYS.ProgramGroupingRepository).to(ProgramGroupingRepository).inSingletonScope();
bind(KEYS.ProgramExternalIdRepository).to(ProgramExternalIdRepository).inSingletonScope();
bind(KEYS.ProgramMetadataRepository).to(ProgramMetadataRepository).inSingletonScope();
bind(KEYS.ProgramGroupingUpsertRepository).to(ProgramGroupingUpsertRepository).inSingletonScope();
bind(KEYS.ProgramSearchRepository).to(ProgramSearchRepository).inSingletonScope();
bind(KEYS.ProgramStateRepository).to(ProgramStateRepository).inSingletonScope();
bind(KEYS.ProgramUpsertRepository).to(ProgramUpsertRepository).inSingletonScope();
// ChannelDB sub-repositories (must be registered before ChannelDB itself)
// LineupRepository must come before BasicChannelRepository (it's injected into it)
bind(KEYS.LineupRepository).to(LineupRepository).inSingletonScope();
bind(KEYS.ChannelConfigRepository).to(ChannelConfigRepository).inSingletonScope();
bind(KEYS.ChannelProgramRepository).to(ChannelProgramRepository).inSingletonScope();
bind(KEYS.BasicChannelRepository).to(BasicChannelRepository).inSingletonScope();
// Main DB facades
bind<IProgramDB>(KEYS.ProgramDB).to(ProgramDB).inSingletonScope();
bind<IChannelDB>(KEYS.ChannelDB).to(ChannelDB).inSingletonScope();
bind<DBAccess>(DBAccess).toSelf().inSingletonScope();

View File

@@ -14,6 +14,14 @@ import { LoggerFactory } from '../util/logging/LoggerFactory.ts';
import { DBAccess } from './DBAccess.ts';
import { IProgramDB } from './interfaces/IProgramDB.ts';
import { ProgramDB } from './ProgramDB.ts';
import { BasicProgramRepository } from './program/BasicProgramRepository.ts';
import { ProgramGroupingRepository } from './program/ProgramGroupingRepository.ts';
import { ProgramExternalIdRepository } from './program/ProgramExternalIdRepository.ts';
import { ProgramUpsertRepository } from './program/ProgramUpsertRepository.ts';
import { ProgramMetadataRepository } from './program/ProgramMetadataRepository.ts';
import { ProgramGroupingUpsertRepository } from './program/ProgramGroupingUpsertRepository.ts';
import { ProgramSearchRepository } from './program/ProgramSearchRepository.ts';
import { ProgramStateRepository } from './program/ProgramStateRepository.ts';
import { NewArtwork } from './schema/Artwork.ts';
import {
MediaSourceId,
@@ -94,16 +102,36 @@ const test = baseTest.extend<Fixture>({
const dbAccess = DBAccess.instance;
const logger = LoggerFactory.child({ className: 'ProgramDB' });
// Mock the task factories required by ProgramDB
// Mock the task factories required by ProgramUpsertRepository
const mockTaskFactory = () => ({ enqueue: async () => {} }) as any;
const programDb = new ProgramDB(
logger,
mockTaskFactory,
mockTaskFactory,
const metadataRepo = new ProgramMetadataRepository(dbAccess.drizzle!);
const externalIdRepo = new ProgramExternalIdRepository(logger, dbAccess.db!, dbAccess.drizzle!);
const groupingUpsertRepo = new ProgramGroupingUpsertRepository(
dbAccess.db!,
() => ({}) as any, // ProgramDaoMinterFactory
dbAccess.drizzle!,
metadataRepo,
);
const upsertRepo = new ProgramUpsertRepository(
logger,
dbAccess.db!,
dbAccess.drizzle!,
mockTaskFactory,
mockTaskFactory,
() => ({}) as any, // ProgramDaoMinterFactory
externalIdRepo,
metadataRepo,
groupingUpsertRepo,
);
const programDb = new ProgramDB(
new BasicProgramRepository(dbAccess.db!, dbAccess.drizzle!),
new ProgramGroupingRepository(logger, dbAccess.db!, dbAccess.drizzle!),
externalIdRepo,
upsertRepo,
metadataRepo,
groupingUpsertRepo,
new ProgramSearchRepository(dbAccess.db!, dbAccess.drizzle!),
new ProgramStateRepository(dbAccess.drizzle!),
);
await use(programDb);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,532 @@
import { ChannelQueryBuilder } from '@/db/ChannelQueryBuilder.js';
import { CacheImageService } from '@/services/cacheImageService.js';
import { ChannelNotFoundError } from '@/types/errors.js';
import { KEYS } from '@/types/inject.js';
import { Result } from '@/types/result.js';
import { Maybe } from '@/types/util.js';
import dayjs from '@/util/dayjs.js';
import { booleanToNumber } from '@/util/sqliteUtil.js';
import type { SaveableChannel, Watermark } from '@tunarr/types';
import { eq } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
import {
isEmpty,
isNil,
isNumber,
isString,
isUndefined,
map,
sum,
} from 'lodash-es';
import { MarkRequired } from 'ts-essentials';
import { v4 } from 'uuid';
import { isDefined, isNonEmptyString } from '../../util/index.ts';
import { ChannelAndLineup } from '../interfaces/IChannelDB.ts';
import {
Channel,
ChannelOrm,
ChannelUpdate,
NewChannel,
} from '../schema/Channel.ts';
import { NewChannelFillerShow } from '../schema/ChannelFillerShow.ts';
import {
ChannelWithRelations,
ChannelOrmWithTranscodeConfig,
} from '../schema/derivedTypes.ts';
import {
NewChannelSubtitlePreference,
} from '../schema/SubtitlePreferences.ts';
import type { DB } from '../schema/db.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
import { LineupRepository } from './LineupRepository.ts';
function sanitizeChannelWatermark(
watermark: Maybe<Watermark>,
): Maybe<Watermark> {
if (isUndefined(watermark)) {
return;
}
const validFadePoints = (watermark.fadeConfig ?? []).filter(
(conf) => conf.periodMins > 0,
);
return {
...watermark,
fadeConfig: isEmpty(validFadePoints) ? undefined : validFadePoints,
};
}
function updateRequestToChannel(updateReq: SaveableChannel): ChannelUpdate {
const sanitizedWatermark = sanitizeChannelWatermark(updateReq.watermark);
return {
number: updateReq.number,
watermark: sanitizedWatermark
? JSON.stringify(sanitizedWatermark)
: undefined,
icon: JSON.stringify(updateReq.icon),
guideMinimumDuration: updateReq.guideMinimumDuration,
groupTitle: updateReq.groupTitle,
disableFillerOverlay: booleanToNumber(updateReq.disableFillerOverlay),
startTime: +dayjs(updateReq.startTime).second(0).millisecond(0),
offline: JSON.stringify(updateReq.offline),
name: updateReq.name,
duration: updateReq.duration,
stealth: booleanToNumber(updateReq.stealth),
fillerRepeatCooldown: updateReq.fillerRepeatCooldown,
guideFlexTitle: updateReq.guideFlexTitle,
transcodeConfigId: updateReq.transcodeConfigId,
streamMode: updateReq.streamMode,
subtitlesEnabled: booleanToNumber(updateReq.subtitlesEnabled),
} satisfies ChannelUpdate;
}
function createRequestToChannel(saveReq: SaveableChannel): NewChannel {
const now = +dayjs();
return {
uuid: v4(),
createdAt: now,
updatedAt: now,
number: saveReq.number,
watermark: saveReq.watermark ? JSON.stringify(saveReq.watermark) : null,
icon: JSON.stringify(saveReq.icon),
guideMinimumDuration: saveReq.guideMinimumDuration,
groupTitle: saveReq.groupTitle,
disableFillerOverlay: saveReq.disableFillerOverlay ? 1 : 0,
startTime: saveReq.startTime,
offline: JSON.stringify(saveReq.offline),
name: saveReq.name,
duration: saveReq.duration,
stealth: saveReq.stealth ? 1 : 0,
fillerRepeatCooldown: saveReq.fillerRepeatCooldown,
guideFlexTitle: saveReq.guideFlexTitle,
streamMode: saveReq.streamMode,
transcodeConfigId: saveReq.transcodeConfigId,
subtitlesEnabled: booleanToNumber(saveReq.subtitlesEnabled),
} satisfies NewChannel;
}
@injectable()
export class BasicChannelRepository {
constructor(
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
@inject(CacheImageService) private cacheImageService: CacheImageService,
@inject(KEYS.LineupRepository) private lineupRepository: LineupRepository,
) {}
async channelExists(channelId: string): Promise<boolean> {
const channel = await this.db
.selectFrom('channel')
.where('channel.uuid', '=', channelId)
.select('uuid')
.executeTakeFirst();
return !isNil(channel);
}
getChannelOrm(
id: string | number,
): Promise<Maybe<ChannelOrmWithTranscodeConfig>> {
return this.drizzleDB.query.channels.findFirst({
where: (channel, { eq }) => {
return isString(id) ? eq(channel.uuid, id) : eq(channel.number, id);
},
with: {
transcodeConfig: true,
},
});
}
getChannel(id: string | number): Promise<Maybe<ChannelWithRelations>>;
getChannel(
id: string | number,
includeFiller: true,
): Promise<Maybe<MarkRequired<ChannelWithRelations, 'fillerShows'>>>;
async getChannel(
id: string | number,
includeFiller: boolean = false,
): Promise<Maybe<ChannelWithRelations>> {
return this.db
.selectFrom('channel')
.$if(isString(id), (eb) => eb.where('channel.uuid', '=', id as string))
.$if(isNumber(id), (eb) => eb.where('channel.number', '=', id as number))
.$if(includeFiller, (eb) =>
eb.select((qb) =>
jsonArrayFrom(
qb
.selectFrom('channelFillerShow')
.whereRef('channel.uuid', '=', 'channelFillerShow.channelUuid')
.select([
'channelFillerShow.channelUuid',
'channelFillerShow.fillerShowUuid',
'channelFillerShow.cooldown',
'channelFillerShow.weight',
]),
).as('fillerShows'),
),
)
.selectAll()
.executeTakeFirst();
}
getChannelBuilder(id: string | number) {
return ChannelQueryBuilder.createForIdOrNumber(this.db, id);
}
getAllChannels(): Promise<ChannelOrm[]> {
return this.drizzleDB.query.channels
.findMany({
orderBy: (fields, { asc }) => asc(fields.number),
})
.execute();
}
async saveChannel(
createReq: SaveableChannel,
): Promise<ChannelAndLineup<Channel>> {
const existing = await this.getChannel(createReq.number);
if (!isNil(existing)) {
throw new Error(
`Channel with number ${createReq.number} already exists: ${existing.name}`,
);
}
const channel = await this.db.transaction().execute(async (tx) => {
const channel = await tx
.insertInto('channel')
.values(createRequestToChannel(createReq))
.returningAll()
.executeTakeFirst();
if (!channel) {
throw new Error('Error while saving new channel.');
}
if (!isEmpty(createReq.fillerCollections)) {
await tx
.insertInto('channelFillerShow')
.values(
map(
createReq.fillerCollections,
(fc) =>
({
channelUuid: channel.uuid,
cooldown: fc.cooldownSeconds,
fillerShowUuid: fc.id,
weight: fc.weight,
}) satisfies NewChannelFillerShow,
),
)
.execute();
}
const subtitlePreferences = createReq.subtitlePreferences?.map(
(pref) =>
({
channelId: channel.uuid,
uuid: v4(),
languageCode: pref.langugeCode,
allowExternal: booleanToNumber(pref.allowExternal),
allowImageBased: booleanToNumber(pref.allowImageBased),
filterType: pref.filter,
priority: pref.priority,
}) satisfies NewChannelSubtitlePreference,
);
if (subtitlePreferences) {
await tx
.insertInto('channelSubtitlePreferences')
.values(subtitlePreferences)
.executeTakeFirstOrThrow();
}
return channel;
});
await this.lineupRepository.createLineup(channel.uuid);
if (isDefined(createReq.onDemand) && createReq.onDemand.enabled) {
const db = await this.lineupRepository.getFileDb(channel.uuid);
await db.update((lineup) => {
lineup.onDemandConfig = {
state: 'paused',
cursor: 0,
};
});
}
return {
channel,
lineup: (await this.lineupRepository.getFileDb(channel.uuid)).data,
};
}
async updateChannel(
id: string,
updateReq: SaveableChannel,
): Promise<ChannelAndLineup<Channel>> {
const channel = await this.getChannel(id);
if (isNil(channel)) {
throw new ChannelNotFoundError(id);
}
const update = updateRequestToChannel(updateReq);
if (
isNonEmptyString(updateReq.watermark?.url) &&
URL.canParse(updateReq.watermark.url)
) {
const url = updateReq.watermark?.url;
const parsed = new URL(url);
if (!parsed.hostname.includes('localhost')) {
await Result.attemptAsync(() =>
this.cacheImageService.getOrDownloadImageUrl(url),
);
}
}
await this.db.transaction().execute(async (tx) => {
await tx
.updateTable('channel')
.where('channel.uuid', '=', id)
.set(update)
.executeTakeFirstOrThrow();
if (!isEmpty(updateReq.fillerCollections)) {
const channelFillerShows = map(
updateReq.fillerCollections,
(filler) =>
({
cooldown: filler.cooldownSeconds,
channelUuid: channel.uuid,
fillerShowUuid: filler.id,
weight: filler.weight,
}) satisfies NewChannelFillerShow,
);
await tx
.deleteFrom('channelFillerShow')
.where('channelFillerShow.channelUuid', '=', channel.uuid)
.executeTakeFirstOrThrow();
await tx
.insertInto('channelFillerShow')
.values(channelFillerShows)
.executeTakeFirstOrThrow();
}
const subtitlePreferences = updateReq.subtitlePreferences?.map(
(pref) =>
({
channelId: channel.uuid,
uuid: v4(),
languageCode: pref.langugeCode,
allowExternal: booleanToNumber(pref.allowExternal),
allowImageBased: booleanToNumber(pref.allowImageBased),
filterType: pref.filter,
priority: pref.priority,
}) satisfies NewChannelSubtitlePreference,
);
await tx
.deleteFrom('channelSubtitlePreferences')
.where('channelSubtitlePreferences.channelId', '=', channel.uuid)
.executeTakeFirstOrThrow();
if (subtitlePreferences) {
await tx
.insertInto('channelSubtitlePreferences')
.values(subtitlePreferences)
.executeTakeFirstOrThrow();
}
});
if (isDefined(updateReq.onDemand)) {
const db = await this.lineupRepository.getFileDb(id);
await db.update((lineup) => {
if (updateReq.onDemand?.enabled ?? false) {
lineup.onDemandConfig = {
state: 'paused',
cursor: 0,
};
} else {
delete lineup['onDemandConfig'];
}
});
}
return {
channel: (await this.getChannel(id, true))!,
lineup: await this.lineupRepository.loadLineup(id),
};
}
updateChannelDuration(id: string, newDur: number): Promise<number> {
return this.drizzleDB
.update(Channel)
.set({
duration: newDur,
})
.where(eq(Channel.uuid, id))
.limit(1)
.execute()
.then((_) => _.changes);
}
async updateChannelStartTime(id: string, newTime: number): Promise<void> {
return this.db
.updateTable('channel')
.where('channel.uuid', '=', id)
.set('startTime', newTime)
.executeTakeFirst()
.then(() => {});
}
async syncChannelDuration(id: string): Promise<boolean> {
const channelAndLineup = await this.lineupRepository.loadChannelAndLineup(id);
if (!channelAndLineup) {
return false;
}
const { channel, lineup } = channelAndLineup;
const lineupDuration = sum(map(lineup.items, (item) => item.durationMs));
if (lineupDuration !== channel.duration) {
await this.db
.updateTable('channel')
.where('channel.uuid', '=', id)
.set('duration', lineupDuration)
.executeTakeFirst();
return true;
}
return false;
}
async copyChannel(id: string): Promise<ChannelAndLineup<Channel>> {
const channel = await this.getChannel(id);
if (!channel) {
throw new Error(`Cannot copy channel: channel ID: ${id} not found`);
}
const lineup = await this.lineupRepository.loadLineup(id);
const newChannelId = v4();
const now = +dayjs();
const newChannel = await this.db.transaction().execute(async (tx) => {
const { number: maxId } = await tx
.selectFrom('channel')
.select('number')
.orderBy('number desc')
.limit(1)
.executeTakeFirstOrThrow();
const newChannel = await tx
.insertInto('channel')
.values({
...channel,
uuid: newChannelId,
name: `${channel.name} - Copy`,
number: maxId + 1,
icon: JSON.stringify(channel.icon),
offline: JSON.stringify(channel.offline),
watermark: JSON.stringify(channel.watermark),
createdAt: now,
updatedAt: now,
transcoding: null,
})
.returningAll()
.executeTakeFirstOrThrow();
await tx
.insertInto('channelFillerShow')
.columns(['channelUuid', 'cooldown', 'fillerShowUuid', 'weight'])
.expression((eb) =>
eb
.selectFrom('channelFillerShow')
.select([
eb.val(newChannelId).as('channelUuid'),
'channelFillerShow.cooldown',
'channelFillerShow.fillerShowUuid',
'channelFillerShow.weight',
])
.where('channelFillerShow.channelUuid', '=', channel.uuid),
)
.executeTakeFirstOrThrow();
await tx
.insertInto('channelPrograms')
.columns(['channelUuid', 'programUuid'])
.expression((eb) =>
eb
.selectFrom('channelPrograms')
.select([
eb.val(newChannelId).as('channelUuid'),
'channelPrograms.programUuid',
])
.where('channelPrograms.channelUuid', '=', channel.uuid),
)
.executeTakeFirstOrThrow();
await tx
.insertInto('channelCustomShows')
.columns(['channelUuid', 'customShowUuid'])
.expression((eb) =>
eb
.selectFrom('channelCustomShows')
.select([
eb.val(newChannelId).as('channelUuid'),
'channelCustomShows.customShowUuid',
])
.where('channelCustomShows.channelUuid', '=', channel.uuid),
)
.executeTakeFirstOrThrow();
return newChannel;
});
const newLineup = await this.lineupRepository.saveLineup(newChannel.uuid, lineup);
return {
channel: newChannel,
lineup: newLineup,
};
}
async deleteChannel(
channelId: string,
blockOnLineupUpdates: boolean = false,
): Promise<void> {
let marked = false;
try {
await this.lineupRepository.markLineupFileForDeletion(channelId);
marked = true;
await this.db.transaction().execute(async (tx) => {
await tx
.deleteFrom('channelSubtitlePreferences')
.where('channelId', '=', channelId)
.executeTakeFirstOrThrow();
await tx
.deleteFrom('channel')
.where('uuid', '=', channelId)
.limit(1)
.executeTakeFirstOrThrow();
});
const removeRefs = () =>
this.lineupRepository.removeRedirectReferences(channelId).catch(() => {
// Errors are logged inside removeRedirectReferences
});
if (blockOnLineupUpdates) {
await removeRefs();
} else {
setTimeout(() => {
removeRefs().catch(() => {});
});
}
} catch (e) {
if (marked) {
await this.lineupRepository.restoreLineupFile(channelId);
}
throw e;
}
}
}

View File

@@ -0,0 +1,23 @@
import { KEYS } from '@/types/inject.js';
import { inject, injectable } from 'inversify';
import type { Kysely } from 'kysely';
import type { DB } from '../schema/db.ts';
import type { ChannelSubtitlePreferences } from '../schema/SubtitlePreferences.ts';
@injectable()
export class ChannelConfigRepository {
constructor(
@inject(KEYS.Database) private db: Kysely<DB>,
) {}
async getChannelSubtitlePreferences(
id: string,
): Promise<ChannelSubtitlePreferences[]> {
return this.db
.selectFrom('channelSubtitlePreferences')
.selectAll()
.where('channelId', '=', id)
.orderBy('priority asc')
.execute();
}
}

View File

@@ -0,0 +1,478 @@
import { KEYS } from '@/types/inject.js';
import type { Maybe, PagedResult } from '@/types/util.js';
import type { ContentProgramType } from '@tunarr/types/schemas';
import { and, asc, count, countDistinct, eq, isNotNull } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import type { Kysely } from 'kysely';
import { chunk, flatten, groupBy, sum, uniq } from 'lodash-es';
import type { MarkRequired } from 'ts-essentials';
import {
createManyRelationAgg,
mapRawJsonRelationResult,
} from '../../util/drizzleUtil.ts';
import type { PageParams } from '../interfaces/IChannelDB.ts';
import { withFallbackPrograms, withPrograms } from '../programQueryHelpers.ts';
import { Artwork } from '../schema/Artwork.ts';
import { ChannelPrograms } from '../schema/ChannelPrograms.ts';
import type { ProgramDao } from '../schema/Program.ts';
import { Program, ProgramType } from '../schema/Program.ts';
import type { ProgramExternalId } from '../schema/ProgramExternalId.ts';
import {
ProgramGrouping,
ProgramGroupingType,
} from '../schema/ProgramGrouping.ts';
import { ProgramGroupingExternalIdOrm } from '../schema/ProgramGroupingExternalId.ts';
import type { DB } from '../schema/db.ts';
import type {
ChannelOrmWithPrograms,
ChannelOrmWithRelations,
ChannelWithPrograms,
MusicArtistOrm,
MusicArtistWithExternalIds,
ProgramGroupingOrmWithRelations,
ProgramWithRelationsOrm,
TvSeasonOrm,
TvShowOrm,
} from '../schema/derivedTypes.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
@injectable()
export class ChannelProgramRepository {
constructor(
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {}
async getChannelAndPrograms(
uuid: string,
typeFilter?: ContentProgramType,
): Promise<Maybe<MarkRequired<ChannelOrmWithRelations, 'programs'>>> {
const channelsAndPrograms = await this.drizzleDB.query.channels.findFirst({
where: (fields, { eq }) => eq(fields.uuid, uuid),
with: {
channelPrograms: {
with: {
program: {
with: {
show: true,
season: true,
artist: true,
album: true,
externalIds: true,
},
},
},
},
},
orderBy: (fields, { asc }) => asc(fields.number),
});
if (channelsAndPrograms) {
const programs = typeFilter
? channelsAndPrograms.channelPrograms
.map(({ program }) => program)
.filter((p) => p.type === typeFilter)
: channelsAndPrograms.channelPrograms.map(({ program }) => program);
return {
...channelsAndPrograms,
programs,
} satisfies MarkRequired<ChannelOrmWithRelations, 'programs'>;
}
return;
}
async getChannelAndProgramsOld(
uuid: string,
): Promise<ChannelWithPrograms | undefined> {
return this.db
.selectFrom('channel')
.selectAll(['channel'])
.where('channel.uuid', '=', uuid)
.leftJoin(
'channelPrograms',
'channel.uuid',
'channelPrograms.channelUuid',
)
.select((eb) =>
withPrograms(eb, {
joins: {
customShows: true,
tvShow: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
tvSeason: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackArtist: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackAlbum: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
},
}),
)
.groupBy('channel.uuid')
.orderBy('channel.number asc')
.executeTakeFirst();
}
async getChannelTvShows(
id: string,
pageParams?: PageParams,
): Promise<PagedResult<TvShowOrm>> {
const groups = await this.drizzleDB
.select({
programGrouping: ProgramGrouping,
artwork: createManyRelationAgg(
this.drizzleDB
.select()
.from(Artwork)
.where(eq(ProgramGrouping.uuid, Artwork.groupingId))
.as('artwork'),
'artwork',
),
})
.from(ChannelPrograms)
.where(
and(
eq(ChannelPrograms.channelUuid, id),
eq(Program.type, ProgramType.Episode),
isNotNull(Program.tvShowUuid),
eq(ProgramGrouping.type, ProgramGroupingType.Show),
),
)
.groupBy(Program.tvShowUuid)
.orderBy(asc(ProgramGrouping.uuid))
.innerJoin(Program, eq(Program.uuid, ChannelPrograms.programUuid))
.innerJoin(ProgramGrouping, eq(ProgramGrouping.uuid, Program.tvShowUuid))
.offset(pageParams?.offset ?? 0)
.limit(pageParams?.limit ?? 1_000_000);
const countPromise = this.drizzleDB
.select({
count: countDistinct(ProgramGrouping.uuid),
})
.from(ChannelPrograms)
.where(
and(
eq(ChannelPrograms.channelUuid, id),
eq(Program.type, ProgramType.Episode),
isNotNull(Program.tvShowUuid),
eq(ProgramGrouping.type, ProgramGroupingType.Show),
),
)
.innerJoin(Program, eq(Program.uuid, ChannelPrograms.programUuid))
.innerJoin(ProgramGrouping, eq(ProgramGrouping.uuid, Program.tvShowUuid));
const externalIdQueries: Promise<ProgramGroupingExternalIdOrm[]>[] = [];
const seasonQueries: Promise<ProgramGroupingOrmWithRelations[]>[] = [];
for (const groupChunk of chunk(groups, 100)) {
const ids = groupChunk.map(({ programGrouping }) => programGrouping.uuid);
externalIdQueries.push(
this.drizzleDB.query.programGroupingExternalId.findMany({
where: (fields, { inArray }) => inArray(fields.groupUuid, ids),
}),
);
seasonQueries.push(
this.drizzleDB.query.programGrouping.findMany({
where: (fields, { eq, and, inArray }) =>
and(
eq(fields.type, ProgramGroupingType.Season),
inArray(fields.showUuid, ids),
),
with: {
externalIds: true,
},
}),
);
}
const [externalIdResults, seasonResults] = await Promise.all([
Promise.all(externalIdQueries).then(flatten),
Promise.all(seasonQueries).then(flatten),
]);
const externalIdsByGroupId = groupBy(
externalIdResults,
(id) => id.groupUuid,
);
const seasonByGroupId = groupBy(seasonResults, (season) => season.showUuid);
const shows: TvShowOrm[] = [];
for (const { programGrouping, artwork } of groups) {
if (programGrouping.type === 'show') {
const seasons =
seasonByGroupId[programGrouping.uuid]?.filter(
(group): group is TvSeasonOrm => group.type === 'season',
) ?? [];
shows.push({
...programGrouping,
type: 'show',
externalIds: externalIdsByGroupId[programGrouping.uuid] ?? [],
seasons,
artwork: mapRawJsonRelationResult(artwork, Artwork),
});
}
}
return {
total: sum((await countPromise).map(({ count }) => count)),
results: shows,
};
}
async getChannelMusicArtists(
id: string,
pageParams?: PageParams,
): Promise<PagedResult<MusicArtistWithExternalIds>> {
const groups = await this.drizzleDB
.select({
programGrouping: ProgramGrouping,
})
.from(ChannelPrograms)
.where(
and(
eq(ChannelPrograms.channelUuid, id),
eq(Program.type, ProgramType.Track),
isNotNull(Program.artistUuid),
eq(ProgramGrouping.type, ProgramGroupingType.Artist),
),
)
.groupBy(Program.artistUuid)
.orderBy(asc(ProgramGrouping.uuid))
.innerJoin(Program, eq(Program.uuid, ChannelPrograms.programUuid))
.innerJoin(ProgramGrouping, eq(ProgramGrouping.uuid, Program.artistUuid))
.offset(pageParams?.offset ?? 0)
.limit(pageParams?.limit ?? 1_000_000);
const countPromise = this.drizzleDB
.select({
count: count(),
})
.from(ChannelPrograms)
.where(
and(
eq(ChannelPrograms.channelUuid, id),
eq(Program.type, ProgramType.Episode),
isNotNull(Program.tvShowUuid),
eq(ProgramGrouping.type, ProgramGroupingType.Show),
),
)
.innerJoin(Program, eq(Program.uuid, ChannelPrograms.programUuid))
.innerJoin(ProgramGrouping, eq(ProgramGrouping.uuid, Program.tvShowUuid));
const externalIdQueries: Promise<ProgramGroupingExternalIdOrm[]>[] = [];
const albumQueries: Promise<ProgramGroupingOrmWithRelations[]>[] = [];
for (const groupChunk of chunk(groups, 100)) {
const ids = groupChunk.map(({ programGrouping }) => programGrouping.uuid);
externalIdQueries.push(
this.drizzleDB.query.programGroupingExternalId.findMany({
where: (fields, { inArray }) => inArray(fields.groupUuid, ids),
}),
);
albumQueries.push(
this.drizzleDB.query.programGrouping.findMany({
where: (fields, { eq, and, inArray }) =>
and(
eq(fields.type, ProgramGroupingType.Season),
inArray(fields.showUuid, ids),
),
with: {
externalIds: true,
},
}),
);
}
const [externalIdResults, albumResults] = await Promise.all([
Promise.all(externalIdQueries).then(flatten),
Promise.all(albumQueries).then(flatten),
]);
const externalIdsByGroupId = groupBy(
externalIdResults,
(id) => id.groupUuid,
);
const seasonByGroupId = groupBy(albumResults, (season) => season.showUuid);
const artists: MusicArtistOrm[] = [];
for (const { programGrouping } of groups) {
if (programGrouping.type === 'artist') {
const albums =
seasonByGroupId[programGrouping.uuid]?.filter(
(group): group is MusicAlbumOrm => group.type === 'album',
) ?? [];
artists.push({
...programGrouping,
type: 'artist',
externalIds: externalIdsByGroupId[programGrouping.uuid] ?? [],
albums,
});
}
}
return {
total: sum((await countPromise).map(({ count }) => count)),
results: artists,
};
}
async getChannelPrograms(
id: string,
pageParams?: PageParams,
typeFilter?: ContentProgramType,
): Promise<PagedResult<ProgramWithRelationsOrm>> {
let query = this.drizzleDB
.select({ programId: ChannelPrograms.programUuid, count: count() })
.from(ChannelPrograms)
.where(
and(
eq(ChannelPrograms.channelUuid, id),
typeFilter ? eq(Program.type, typeFilter) : undefined,
),
)
.innerJoin(Program, eq(ChannelPrograms.programUuid, Program.uuid))
.$dynamic();
const countResult = (await query.execute())[0]?.count ?? 0;
if (pageParams) {
query = query
.groupBy(Program.uuid)
.orderBy(asc(Program.title))
.offset(pageParams.offset)
.limit(pageParams.limit);
}
const results = await query.execute();
const materialized: ProgramWithRelationsOrm[] = [];
for (const idChunk of chunk(
results.map(({ programId }) => programId),
100,
)) {
materialized.push(
...(await this.drizzleDB.query.program.findMany({
where: (fields, { inArray }) => inArray(fields.uuid, idChunk),
with: {
externalIds: true,
album: true,
artist: true,
season: true,
show: true,
artwork: true,
subtitles: true,
credits: true,
versions: {
with: {
mediaStreams: true,
mediaFiles: true,
chapters: true,
},
},
},
orderBy: (fields, { asc }) => asc(fields.uuid),
})),
);
}
return { results: materialized, total: countResult };
}
getChannelProgramExternalIds(uuid: string): Promise<ProgramExternalId[]> {
return this.db
.selectFrom('channelPrograms')
.where('channelUuid', '=', uuid)
.innerJoin(
'programExternalId',
'channelPrograms.programUuid',
'programExternalId.programUuid',
)
.selectAll('programExternalId')
.execute();
}
async getChannelFallbackPrograms(uuid: string): Promise<ProgramDao[]> {
const result = await this.db
.selectFrom('channelFallback')
.where('channelFallback.channelUuid', '=', uuid)
.select(withFallbackPrograms)
.groupBy('channelFallback.channelUuid')
.executeTakeFirst();
return result?.programs ?? [];
}
async replaceChannelPrograms(
channelId: string,
programIds: string[],
): Promise<void> {
const uniqueIds = uniq(programIds);
await this.drizzleDB.transaction(async (tx) => {
await tx
.delete(ChannelPrograms)
.where(eq(ChannelPrograms.channelUuid, channelId));
for (const c of chunk(uniqueIds, 250)) {
await tx
.insert(ChannelPrograms)
.values(c.map((id) => ({ channelUuid: channelId, programUuid: id })));
}
});
}
findChannelsForProgramId(programId: string): Promise<ChannelOrm[]> {
return this.drizzleDB.query.channelPrograms
.findMany({
where: (cp, { eq }) => eq(cp.programUuid, programId),
with: {
channel: true,
},
})
.then((result) => result.map((row) => row.channel));
}
async getAllChannelsAndPrograms(): Promise<ChannelOrmWithPrograms[]> {
return await this.drizzleDB.query.channels
.findMany({
with: {
channelPrograms: {
with: {
program: {
with: {
album: true,
artist: true,
show: true,
season: true,
externalIds: true,
},
},
},
},
},
orderBy: (fields, { asc }) => asc(fields.number),
})
.then((result) => {
return result.map((channel) => {
const { omit } = require('lodash-es');
const withoutJoinTable = omit(channel, 'channelPrograms');
return {
...withoutJoinTable,
programs: channel.channelPrograms.map((cp) => cp.program),
} satisfies ChannelOrmWithPrograms;
});
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import { KEYS } from '@/types/inject.js';
import type { Maybe } from '@/types/util.js';
import type { ProgramExternalId } from '../schema/ProgramExternalId.ts';
import { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
import type { ProgramWithRelationsOrm } from '../schema/derivedTypes.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
import type { DB } from '../schema/db.ts';
import { inject, injectable } from 'inversify';
import type { Kysely } from 'kysely';
import { chunk, isEmpty } from 'lodash-es';
import type { MarkRequired } from 'ts-essentials';
@injectable()
export class BasicProgramRepository {
constructor(
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {}
async getProgramById(
id: string,
): Promise<Maybe<MarkRequired<ProgramWithRelationsOrm, 'externalIds'>>> {
return this.drizzleDB.query.program.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
with: {
externalIds: true,
artwork: true,
subtitles: true,
credits: true,
versions: {
with: {
mediaStreams: true,
mediaFiles: true,
chapters: true,
},
},
},
});
}
async getProgramExternalIds(
id: string,
externalIdTypes?: ProgramExternalIdType[],
): Promise<ProgramExternalId[]> {
return await this.db
.selectFrom('programExternalId')
.selectAll()
.where('programExternalId.programUuid', '=', id)
.$if(!isEmpty(externalIdTypes), (qb) =>
qb.where('programExternalId.sourceType', 'in', externalIdTypes!),
)
.execute();
}
async getShowIdFromTitle(title: string): Promise<Maybe<string>> {
const matchedGrouping = await this.db
.selectFrom('programGrouping')
.select('uuid')
.where('title', '=', title)
.where('type', '=', ProgramGroupingType.Show)
.executeTakeFirst();
return matchedGrouping?.uuid;
}
async updateProgramDuration(
programId: string,
duration: number,
): Promise<void> {
await this.db
.updateTable('program')
.where('uuid', '=', programId)
.set({
duration,
})
.executeTakeFirst();
}
async getProgramsByIds(
ids: string[] | readonly string[],
batchSize: number = 500,
): Promise<ProgramWithRelationsOrm[]> {
const results: ProgramWithRelationsOrm[] = [];
for (const idChunk of chunk(ids, batchSize)) {
const res = await this.drizzleDB.query.program.findMany({
where: (fields, { inArray }) => inArray(fields.uuid, idChunk),
with: {
album: {
with: {
artwork: true,
},
},
artist: true,
season: true,
show: {
with: {
artwork: true,
},
},
externalIds: true,
artwork: true,
tags: {
with: {
tag: true,
},
},
},
});
results.push(...res);
}
return results;
}
}

View File

@@ -0,0 +1,375 @@
import { KEYS } from '@/types/inject.js';
import type { Logger } from '@/util/logging/LoggerFactory.js';
import { seq } from '@tunarr/shared/util';
import { flatMapAsyncSeq, isNonEmptyString } from '../../util/index.ts';
import { createExternalId } from '@tunarr/shared';
import { inject, injectable } from 'inversify';
import type { Kysely } from 'kysely';
import { chunk, first, isEmpty, isNil, last, map, mapValues } from 'lodash-es';
import type { MarkOptional, MarkRequired } from 'ts-essentials';
import dayjs from 'dayjs';
import { v4 } from 'uuid';
import { ProgramExternalIdType } from '../custom_types/ProgramExternalIdType.ts';
import { programSourceTypeFromString } from '../custom_types/ProgramSourceType.ts';
import {
withProgramByExternalId,
} from '../programQueryHelpers.ts';
import type {
MinimalProgramExternalId,
NewProgramExternalId,
NewSingleOrMultiExternalId,
ProgramExternalId,
} from '../schema/ProgramExternalId.ts';
import { toInsertableProgramExternalId } from '../schema/ProgramExternalId.ts';
import type { MediaSourceId, RemoteSourceType } from '../schema/base.ts';
import type { DB } from '../schema/db.ts';
import type {
ProgramWithRelationsOrm,
} from '../schema/derivedTypes.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
import type { ProgramType } from '../schema/Program.ts';
import { groupByUniq } from '../../util/index.ts';
import type { RemoteMediaSourceType } from '../schema/MediaSource.ts';
import type { ProgramDao } from '../schema/Program.ts';
import type { Dictionary } from 'ts-essentials';
import { groupBy, flatten, partition } from 'lodash-es';
import { mapAsyncSeq } from '../../util/index.ts';
@injectable()
export class ProgramExternalIdRepository {
constructor(
@inject(KEYS.Logger) private logger: Logger,
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {}
async lookupByExternalId(eid: {
sourceType: RemoteSourceType;
externalSourceId: MediaSourceId;
externalKey: string;
}) {
return first(
await this.lookupByExternalIds(
new Set([[eid.sourceType, eid.externalSourceId, eid.externalKey]]),
),
);
}
async lookupByExternalIds(
ids:
| Set<[RemoteSourceType, MediaSourceId, string]>
| Set<readonly [RemoteSourceType, MediaSourceId, string]>,
chunkSize: number = 200,
) {
const allIds = [...ids];
const programs: MarkRequired<ProgramWithRelationsOrm, 'externalIds'>[] = [];
for (const idChunk of chunk(allIds, chunkSize)) {
const results = await this.drizzleDB.query.programExternalId.findMany({
where: (fields, { or, and, eq }) => {
const ands = idChunk.map(([ps, es, ek]) =>
and(
eq(fields.externalKey, ek),
eq(fields.sourceType, ps),
eq(fields.mediaSourceId, es),
),
);
return or(...ands);
},
with: {
program: {
with: {
album: true,
artist: true,
season: true,
show: true,
externalIds: true,
tags: {
with: {
tag: true,
},
},
artwork: true,
credits: true,
genres: {
with: {
genre: true,
},
},
studios: {
with: {
studio: true,
},
},
versions: true,
},
},
},
});
programs.push(...seq.collect(results, (r) => r.program));
}
return programs;
}
async lookupByMediaSource(
sourceType: RemoteMediaSourceType,
sourceId: MediaSourceId,
programType: ProgramType | undefined,
chunkSize: number = 200,
): Promise<ProgramDao[]> {
const programs: ProgramDao[] = [];
let chunk: ProgramDao[] = [];
let lastId: string | undefined;
do {
const result = await this.db
.selectFrom('programExternalId')
.select('programExternalId.uuid')
.select((eb) =>
withProgramByExternalId(eb, { joins: {} }, (qb) =>
qb.$if(!!programType, (eb) =>
eb.where('program.type', '=', programType!),
),
),
)
.where('programExternalId.sourceType', '=', sourceType)
.where('programExternalId.mediaSourceId', '=', sourceId)
.$if(!!lastId, (x) => x.where('programExternalId.uuid', '>', lastId!))
.orderBy('programExternalId.uuid asc')
.limit(chunkSize)
.execute();
chunk = seq.collect(result, (eid) => eid.program);
programs.push(...chunk);
lastId = last(result)?.uuid;
} while (chunk.length > 0);
return programs;
}
async programIdsByExternalIds(
ids: Set<[string, MediaSourceId, string]>,
chunkSize: number = 50,
) {
if (ids.size === 0) {
return {};
}
const externalIds = await flatMapAsyncSeq(
chunk([...ids], chunkSize),
(idChunk) => {
return this.db
.selectFrom('programExternalId')
.selectAll()
.where((eb) =>
eb.or(
map(idChunk, ([ps, es, ek]) => {
return eb.and([
eb('programExternalId.externalKey', '=', ek),
eb('programExternalId.mediaSourceId', '=', es),
eb(
'programExternalId.sourceType',
'=',
programSourceTypeFromString(ps)!,
),
]);
}),
),
)
.execute();
},
);
return mapValues(
groupByUniq(externalIds, (eid) =>
createExternalId(eid.sourceType, eid.mediaSourceId!, eid.externalKey),
),
(eid) => eid.programUuid,
);
}
async updateProgramPlexRatingKey(
programId: string,
serverId: MediaSourceId,
details: MarkOptional<
Pick<
ProgramExternalId,
'externalKey' | 'directFilePath' | 'externalFilePath'
>,
'directFilePath' | 'externalFilePath'
>,
) {
const existingRatingKey = await this.db
.selectFrom('programExternalId')
.selectAll()
.where((eb) =>
eb.and({
programUuid: programId,
mediaSourceId: serverId,
sourceType: ProgramExternalIdType.PLEX,
}),
)
.executeTakeFirst();
if (isNil(existingRatingKey)) {
const now = +dayjs();
return await this.db
.insertInto('programExternalId')
.values({
uuid: v4(),
createdAt: now,
updatedAt: now,
programUuid: programId,
sourceType: ProgramExternalIdType.PLEX,
mediaSourceId: serverId,
...details,
})
.returningAll()
.executeTakeFirstOrThrow();
} else {
await this.db
.updateTable('programExternalId')
.set({
externalKey: details.externalKey,
})
.$if(isNonEmptyString(details.externalFilePath), (_) =>
_.set({
externalFilePath: details.externalFilePath!,
}),
)
.$if(isNonEmptyString(details.directFilePath), (_) =>
_.set({
directFilePath: details.directFilePath!,
}),
)
.where('uuid', '=', existingRatingKey.uuid)
.executeTakeFirst();
return await this.db
.selectFrom('programExternalId')
.selectAll()
.where('uuid', '=', existingRatingKey.uuid)
.executeTakeFirstOrThrow();
}
}
async replaceProgramExternalId(
programId: string,
newExternalId: NewProgramExternalId,
oldExternalId?: MinimalProgramExternalId,
) {
await this.db.transaction().execute(async (tx) => {
if (oldExternalId) {
await tx
.deleteFrom('programExternalId')
.where('programExternalId.programUuid', '=', programId)
.where(
'programExternalId.externalKey',
'=',
oldExternalId.externalKey,
)
.where(
'programExternalId.externalSourceId',
'=',
oldExternalId.externalSourceId,
)
.where('programExternalId.sourceType', '=', oldExternalId.sourceType)
.execute();
}
await tx.insertInto('programExternalId').values(newExternalId).execute();
});
}
async upsertProgramExternalIds(
externalIds: NewSingleOrMultiExternalId[],
chunkSize: number = 100,
): Promise<Dictionary<ProgramExternalId[]>> {
if (isEmpty(externalIds)) {
return {};
}
const logger = this.logger;
const [singles, multiples] = partition(
externalIds,
(id) => id.type === 'single',
);
let singleIdPromise: Promise<ProgramExternalId[]>;
if (!isEmpty(singles)) {
singleIdPromise = mapAsyncSeq(
chunk(singles, chunkSize),
(singleChunk) => {
return this.db.transaction().execute((tx) =>
tx
.insertInto('programExternalId')
.values(singleChunk.map(toInsertableProgramExternalId))
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType'])
.where('mediaSourceId', 'is', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returningAll()
.execute(),
);
},
).then(flatten);
} else {
singleIdPromise = Promise.resolve([]);
}
let multiIdPromise: Promise<ProgramExternalId[]>;
if (!isEmpty(multiples)) {
multiIdPromise = mapAsyncSeq(
chunk(multiples, chunkSize),
(multiChunk) => {
return this.db.transaction().execute((tx) =>
tx
.insertInto('programExternalId')
.values(multiChunk.map(toInsertableProgramExternalId))
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType', 'mediaSourceId'])
.where('mediaSourceId', 'is not', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returningAll()
.execute(),
);
},
).then(flatten);
} else {
multiIdPromise = Promise.resolve([]);
}
const [singleResult, multiResult] = await Promise.allSettled([
singleIdPromise,
multiIdPromise,
]);
const allExternalIds: ProgramExternalId[] = [];
if (singleResult.status === 'rejected') {
logger.error(singleResult.reason, 'Error saving external IDs');
} else {
logger.trace('Upserted %d external IDs', singleResult.value.length);
allExternalIds.push(...singleResult.value);
}
if (multiResult.status === 'rejected') {
logger.error(multiResult.reason, 'Error saving external IDs');
} else {
logger.trace('Upserted %d external IDs', multiResult.value.length);
allExternalIds.push(...multiResult.value);
}
return groupBy(allExternalIds, (eid) => eid.programUuid);
}
}

View File

@@ -0,0 +1,610 @@
import type {
ProgramGroupingExternalIdLookup,
WithChannelIdFilter,
} from '@/db/interfaces/IProgramDB.js';
import { KEYS } from '@/types/inject.js';
import type { Maybe, PagedResult } from '@/types/util.js';
import { type Logger } from '@/util/logging/LoggerFactory.js';
import { seq } from '@tunarr/shared/util';
import { untag } from '@tunarr/types';
import {
and,
asc,
count,
countDistinct,
eq,
} from 'drizzle-orm';
import type { SelectedFields, SQLiteSelectBuilder } from 'drizzle-orm/sqlite-core';
import { inject, injectable } from 'inversify';
import type { Kysely } from 'kysely';
import { chunk, isEmpty, isUndefined, orderBy, sum, uniq } from 'lodash-es';
import { Artwork } from '../schema/Artwork.ts';
import { ChannelPrograms } from '../schema/ChannelPrograms.ts';
import { Program, ProgramType } from '../schema/Program.ts';
import {
ProgramGrouping,
ProgramGroupingType,
type ProgramGroupingTypes,
} from '../schema/ProgramGrouping.ts';
import { ProgramExternalId } from '../schema/ProgramExternalId.ts';
import { ProgramGroupingExternalId } from '../schema/ProgramGroupingExternalId.ts';
import type { MediaSourceId, RemoteSourceType } from '../schema/base.ts';
import type { DB } from '../schema/db.ts';
import type {
MusicAlbumOrm,
ProgramGroupingOrmWithRelations,
ProgramGroupingWithExternalIds,
ProgramWithRelationsOrm,
TvSeasonOrm,
} from '../schema/derivedTypes.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
import type { MarkRequired } from 'ts-essentials';
import {
createManyRelationAgg,
mapRawJsonRelationResult,
} from '../../util/drizzleUtil.ts';
import { selectProgramsBuilder } from '../programQueryHelpers.ts';
import type { PageParams } from '../interfaces/IChannelDB.ts';
import type {
ProgramGroupingChildCounts,
} from '../interfaces/IProgramDB.ts';
@injectable()
export class ProgramGroupingRepository {
constructor(
@inject(KEYS.Logger) private logger: Logger,
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {}
async getProgramGrouping(id: string) {
return this.drizzleDB.query.programGrouping.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
with: {
externalIds: true,
artwork: true,
},
});
}
async getProgramGroupings(
ids: string[],
): Promise<Record<string, ProgramGroupingOrmWithRelations>> {
if (ids.length === 0) {
return {};
}
const uniqueIds = uniq(ids);
const results = await Promise.allSettled(
chunk(uniqueIds, 1000).map((idChunk) => {
return this.drizzleDB.query.programGrouping.findMany({
where: (fields, { inArray }) => inArray(fields.uuid, idChunk),
with: {
externalIds: true,
artwork: true,
artist: true,
show: true,
credits: true,
tags: {
with: {
tag: true,
},
},
},
});
}),
);
const map: Record<string, ProgramGroupingOrmWithRelations> = {};
for (const result of results) {
if (result.status === 'rejected') {
this.logger.error(
result.reason,
'Error while querying for program groupings. Returning partial data.',
);
continue;
}
for (const grouping of result.value) {
map[grouping.uuid] = grouping;
}
}
return map;
}
async getProgramGroupingByExternalId(
eid: ProgramGroupingExternalIdLookup,
): Promise<Maybe<ProgramGroupingOrmWithRelations>> {
return await this.drizzleDB.query.programGroupingExternalId
.findFirst({
where: (row, { and, or, eq }) =>
and(
eq(row.externalKey, eid.externalKey),
eq(row.sourceType, eid.sourceType),
or(
eq(row.externalSourceId, untag(eid.externalSourceId)),
eq(row.mediaSourceId, eid.externalSourceId),
),
),
with: {
grouping: {
with: {
externalIds: true,
},
},
},
})
.then((result) => result?.grouping ?? undefined);
}
async getProgramGroupingsByExternalIds(
eids:
| Set<[RemoteSourceType, MediaSourceId, string]>
| Set<readonly [RemoteSourceType, MediaSourceId, string]>,
chunkSize: number = 100,
) {
const allIds = [...eids];
const programs: MarkRequired<
ProgramGroupingOrmWithRelations,
'externalIds'
>[] = [];
for (const idChunk of chunk(allIds, chunkSize)) {
const results =
await this.drizzleDB.query.programGroupingExternalId.findMany({
where: (fields, { or, and, eq }) => {
const ands = idChunk.map(([ps, es, ek]) =>
and(
eq(fields.externalKey, ek),
eq(fields.sourceType, ps),
eq(fields.mediaSourceId, es),
),
);
return or(...ands);
},
columns: {},
with: {
grouping: {
with: {
artist: true,
show: true,
externalIds: true,
},
},
},
});
programs.push(...seq.collect(results, (r) => r.grouping));
}
return programs;
}
async getProgramParent(
programId: string,
): Promise<Maybe<ProgramGroupingWithExternalIds>> {
const p = await selectProgramsBuilder(this.db, {
joins: { tvSeason: true, trackAlbum: true },
})
.where('program.uuid', '=', programId)
.executeTakeFirst()
.then((program) => program?.tvSeason ?? program?.trackAlbum);
if (p) {
const eids = await this.db
.selectFrom('programGroupingExternalId')
.where('groupUuid', '=', p.uuid)
.selectAll()
.execute();
return {
...p,
externalIds: eids,
};
}
return;
}
getChildren(
parentId: string,
parentType: 'season' | 'album',
params?: WithChannelIdFilter<PageParams>,
): Promise<PagedResult<ProgramWithRelationsOrm>>;
getChildren(
parentId: string,
parentType: 'artist',
params?: WithChannelIdFilter<PageParams>,
): Promise<PagedResult<MusicAlbumOrm>>;
getChildren(
parentId: string,
parentType: 'show',
params?: WithChannelIdFilter<PageParams>,
): Promise<PagedResult<TvSeasonOrm>>;
getChildren(
parentId: string,
parentType: 'artist' | 'show',
params?: WithChannelIdFilter<PageParams>,
): Promise<PagedResult<ProgramGroupingOrmWithRelations>>;
async getChildren(
parentId: string,
parentType: ProgramGroupingType,
params?: WithChannelIdFilter<PageParams>,
): Promise<
| PagedResult<ProgramWithRelationsOrm>
| PagedResult<ProgramGroupingOrmWithRelations>
> {
if (parentType === 'album' || parentType === 'season') {
return this.getTerminalChildren(parentId, parentType, params);
} else {
return this.getGroupingChildren(parentId, parentType, params);
}
}
private async getGroupingChildren(
parentId: string,
parentType: ProgramGroupingTypes['Show'] | ProgramGroupingTypes['Artist'],
params?: WithChannelIdFilter<PageParams>,
) {
const childType = parentType === 'artist' ? 'album' : 'season';
function builder<
TSelection extends SelectedFields | undefined,
TResultType extends 'sync' | 'async',
TRunResult,
>(f: SQLiteSelectBuilder<TSelection, TResultType, TRunResult>) {
return f
.from(Program)
.where(
and(
eq(
Program.type,
parentType === ProgramGroupingType.Show
? ProgramType.Episode
: ProgramType.Track,
),
eq(
parentType === ProgramGroupingType.Show
? Program.tvShowUuid
: Program.artistUuid,
parentId,
),
params?.channelId
? eq(ChannelPrograms.channelUuid, params.channelId)
: undefined,
),
);
}
const sq = this.drizzleDB
.select()
.from(ProgramGroupingExternalId)
.where(eq(ProgramGroupingExternalId.groupUuid, ProgramGrouping.uuid))
.as('sq');
const baseQuery = builder(
this.drizzleDB.select({
grouping: ProgramGrouping,
externalIds: createManyRelationAgg(sq, 'external_ids'),
artwork: createManyRelationAgg(
this.drizzleDB
.select()
.from(Artwork)
.where(eq(ProgramGrouping.uuid, Artwork.groupingId))
.as('artwork'),
'artwork',
),
}),
)
.innerJoin(
ProgramGrouping,
eq(
childType === 'season' ? Program.seasonUuid : Program.albumUuid,
ProgramGrouping.uuid,
),
)
.orderBy(asc(ProgramGrouping.index))
.offset(params?.offset ?? 0)
.limit(params?.limit ?? 1_000_000)
.groupBy(ProgramGrouping.uuid);
const baseCountQuery = builder(
this.drizzleDB.select({
count: countDistinct(ProgramGrouping.uuid),
}),
)
.innerJoin(
ProgramGrouping,
eq(
childType === 'season' ? Program.seasonUuid : Program.albumUuid,
ProgramGrouping.uuid,
),
)
.groupBy(ProgramGrouping.uuid);
if (params?.channelId) {
const res = await baseQuery.innerJoin(
ChannelPrograms,
eq(ChannelPrograms.programUuid, Program.uuid),
);
const cq = baseCountQuery.innerJoin(
ChannelPrograms,
eq(ChannelPrograms.programUuid, Program.uuid),
);
const programs = res.map(({ grouping, externalIds, artwork }) => {
const withRelations = grouping as ProgramGroupingOrmWithRelations;
withRelations.externalIds = mapRawJsonRelationResult(
externalIds,
ProgramGroupingExternalId,
);
withRelations.artwork = mapRawJsonRelationResult(artwork, Artwork);
return withRelations;
});
return {
total: sum((await cq).map(({ count }) => count)),
results: programs,
};
} else {
const res = await baseQuery;
const programs = res.map(({ grouping, externalIds, artwork }) => {
const withRelations = grouping as ProgramGroupingOrmWithRelations;
withRelations.externalIds = mapRawJsonRelationResult(
externalIds,
ProgramGroupingExternalId,
);
withRelations.artwork = mapRawJsonRelationResult(artwork, Artwork);
return withRelations;
});
return {
total: sum((await baseCountQuery).map(({ count }) => count)),
results: programs,
};
}
}
private async getTerminalChildren(
parentId: string,
parentType: ProgramGroupingTypes['Season'] | ProgramGroupingTypes['Album'],
params?: WithChannelIdFilter<PageParams>,
) {
function builder<
TSelection extends SelectedFields | undefined,
TResultType extends 'sync' | 'async',
TRunResult,
>(f: SQLiteSelectBuilder<TSelection, TResultType, TRunResult>) {
return f
.from(Program)
.where(
and(
eq(
Program.type,
parentType === ProgramGroupingType.Album
? ProgramType.Track
: ProgramType.Episode,
),
eq(
parentType === ProgramGroupingType.Album
? Program.albumUuid
: Program.seasonUuid,
parentId,
),
params?.channelId
? eq(ChannelPrograms.channelUuid, params.channelId)
: undefined,
),
);
}
const sq = this.drizzleDB
.select()
.from(ProgramExternalId)
.where(eq(ProgramExternalId.programUuid, Program.uuid))
.as('sq');
const baseQuery = builder(
this.drizzleDB.select({
program: Program,
externalIds: createManyRelationAgg(sq, 'external_ids'),
artwork: createManyRelationAgg(
this.drizzleDB
.select()
.from(Artwork)
.where(eq(Artwork.programId, Program.uuid))
.as('artwork'),
'artwork',
),
}),
).orderBy(asc(Program.seasonNumber), asc(Program.episode));
const baseCountQuery = builder(
this.drizzleDB.select({
count: count(),
}),
);
if (params?.channelId) {
const res = await baseQuery
.offset(params?.offset ?? 0)
.limit(params?.limit ?? 1_000_000)
.innerJoin(
ChannelPrograms,
eq(ChannelPrograms.programUuid, Program.uuid),
);
const cq = baseCountQuery.innerJoin(
ChannelPrograms,
eq(ChannelPrograms.programUuid, Program.uuid),
);
const programs = res.map(({ program, externalIds, artwork }) => {
const withRelations: ProgramWithRelationsOrm = program;
withRelations.externalIds = mapRawJsonRelationResult(
externalIds,
ProgramExternalId,
);
withRelations.artwork = mapRawJsonRelationResult(artwork, Artwork);
return withRelations;
});
console.log(programs);
return {
total: sum((await cq).map(({ count }) => count)),
results: programs,
};
} else {
const res = await baseQuery;
const programs = res.map(({ program, externalIds, artwork }) => {
const withRelations: ProgramWithRelationsOrm = program;
withRelations.externalIds = mapRawJsonRelationResult(
externalIds,
ProgramExternalId,
);
withRelations.artwork = mapRawJsonRelationResult(artwork, Artwork);
return withRelations;
});
return {
total: sum((await baseCountQuery).map(({ count }) => count)),
results: programs,
};
}
}
async getProgramGroupingChildCounts(
groupingIds: string[],
): Promise<Record<string, ProgramGroupingChildCounts>> {
if (isEmpty(groupingIds)) {
return {};
}
const uniqueIds = uniq(groupingIds);
const allResults = await Promise.allSettled(
chunk(uniqueIds, 1000).map((idChunk) =>
this.db
.selectFrom('programGrouping as pg')
.where('pg.uuid', 'in', idChunk)
.leftJoin('program as p', (j) =>
j.on((eb) =>
eb.or([
eb('pg.uuid', '=', eb.ref('p.tvShowUuid')),
eb('pg.uuid', '=', eb.ref('p.artistUuid')),
eb('pg.uuid', '=', eb.ref('p.seasonUuid')),
eb('pg.uuid', '=', eb.ref('p.albumUuid')),
]),
),
)
.leftJoin('programGrouping as pg2', (j) =>
j.on((eb) =>
eb.or([
eb('pg.uuid', '=', eb.ref('pg2.artistUuid')),
eb('pg.uuid', '=', eb.ref('pg2.showUuid')),
]),
),
)
.select(['pg.uuid as uuid', 'pg.type as type'])
.select((eb) =>
eb.fn.count<number>('p.uuid').distinct().as('programCount'),
)
.select((eb) =>
eb.fn.count<number>('pg2.uuid').distinct().as('childGroupCount'),
)
.groupBy('pg.uuid')
.execute(),
),
);
const map: Record<string, ProgramGroupingChildCounts> = {};
for (const result of allResults) {
if (result.status === 'rejected') {
this.logger.error(
result.reason,
'Failed querying program grouping children. Continuing with partial results',
);
continue;
}
for (const counts of result.value) {
map[counts.uuid] = {
type: counts.type,
childCount:
counts.type === 'season' || counts.type === 'album'
? counts.programCount
: counts.childGroupCount,
grandchildCount:
counts.type === 'artist' || counts.type === 'show'
? counts.programCount
: undefined,
};
}
}
return map;
}
async getProgramGroupingDescendants(
groupId: string,
groupTypeHint?: ProgramGroupingType,
): Promise<ProgramWithRelationsOrm[]> {
const programs = await this.drizzleDB.query.program.findMany({
where: (fields, { or, eq }) => {
if (groupTypeHint) {
switch (groupTypeHint) {
case 'show':
return eq(fields.tvShowUuid, groupId);
case 'season':
return eq(fields.seasonUuid, groupId);
case 'artist':
return eq(fields.artistUuid, groupId);
case 'album':
return eq(fields.albumUuid, groupId);
}
} else {
return or(
eq(fields.albumUuid, groupId),
eq(fields.artistUuid, groupId),
eq(fields.tvShowUuid, groupId),
eq(fields.seasonUuid, groupId),
);
}
},
with: {
album:
isUndefined(groupTypeHint) ||
groupTypeHint === 'album' ||
groupTypeHint === 'artist'
? true
: undefined,
artist:
isUndefined(groupTypeHint) ||
groupTypeHint === 'album' ||
groupTypeHint === 'artist'
? true
: undefined,
season:
isUndefined(groupTypeHint) ||
groupTypeHint === 'show' ||
groupTypeHint === 'season'
? true
: undefined,
show:
isUndefined(groupTypeHint) ||
groupTypeHint === 'show' ||
groupTypeHint === 'season'
? true
: undefined,
externalIds: true,
},
});
return orderBy(
programs,
[(p) => p.season?.index ?? p.seasonNumber ?? 1, (p) => p.episode ?? 1],
['asc', 'asc'],
);
}
}

View File

@@ -0,0 +1,494 @@
import type {
ProgramGroupingExternalIdLookup,
UpsertResult,
} from '@/db/interfaces/IProgramDB.js';
import { KEYS } from '@/types/inject.js';
import { devAssert } from '@/util/debug.js';
import { seq } from '@tunarr/shared/util';
import { untag } from '@tunarr/types';
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
import type { RunResult } from 'better-sqlite3';
import { and, isNull as dbIsNull, eq, or, sql } from 'drizzle-orm';
import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
import { inject, injectable } from 'inversify';
import type { InsertResult, Kysely } from 'kysely';
import {
chunk,
compact,
head,
isNil,
keys,
omit,
partition,
uniq,
} from 'lodash-es';
import { P, match } from 'ts-pattern';
import { groupByUniq, isDefined } from '../../util/index.ts';
import {
ProgramGrouping,
type NewProgramGroupingOrm,
type ProgramGroupingOrm,
} from '../schema/ProgramGrouping.ts';
import {
ProgramGroupingExternalId,
toInsertableProgramGroupingExternalId,
type NewProgramGroupingExternalId,
type NewSingleOrMultiProgramGroupingExternalId,
type ProgramGroupingExternalIdOrm,
} from '../schema/ProgramGroupingExternalId.ts';
import type { DB } from '../schema/db.ts';
import type {
NewProgramGroupingWithRelations,
ProgramGroupingOrmWithRelations,
} from '../schema/derivedTypes.ts';
import type { DrizzleDBAccess, schema } from '../schema/index.ts';
import { ProgramMetadataRepository } from './ProgramMetadataRepository.ts';
@injectable()
export class ProgramGroupingUpsertRepository {
constructor(
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
@inject(KEYS.ProgramMetadataRepository)
private metadataRepo: ProgramMetadataRepository,
) {}
async upsertProgramGrouping(
newGroupingAndRelations: NewProgramGroupingWithRelations,
forceUpdate: boolean = false,
): Promise<UpsertResult<ProgramGroupingOrmWithRelations>> {
let entity: ProgramGroupingOrmWithRelations | undefined =
await this.getProgramGrouping(
newGroupingAndRelations.programGrouping.uuid,
);
let shouldUpdate = forceUpdate;
let wasInserted = false,
wasUpdated = false;
const { programGrouping: dao, externalIds } = newGroupingAndRelations;
if (!entity && dao.sourceType === 'local') {
const incomingYear = newGroupingAndRelations.programGrouping.year;
entity = await this.drizzleDB.query.programGrouping.findFirst({
where: (fields, { eq, and, isNull }) => {
const parentClause = match(newGroupingAndRelations.programGrouping)
.with({ type: 'season', showUuid: P.nonNullable }, (season) =>
compact([
eq(fields.showUuid, season.showUuid),
season.index ? eq(fields.index, season.index) : null,
]),
)
.with({ type: 'album', artistUuid: P.nonNullable }, (album) => [
eq(fields.artistUuid, album.artistUuid),
])
.otherwise(() => []);
return and(
eq(
fields.libraryId,
newGroupingAndRelations.programGrouping.libraryId,
),
eq(fields.title, newGroupingAndRelations.programGrouping.title),
eq(fields.type, newGroupingAndRelations.programGrouping.type),
eq(fields.sourceType, 'local'),
isNil(incomingYear)
? isNull(fields.year)
: eq(fields.year, incomingYear),
...parentClause,
);
},
with: {
externalIds: true,
},
});
} else if (!entity && dao.sourceType !== 'local') {
entity = await this.getProgramGroupingByExternalId({
sourceType: dao.sourceType,
externalKey: dao.externalKey,
externalSourceId: dao.mediaSourceId,
});
if (entity) {
const missingAssociation =
(entity.type === 'season' &&
isDefined(dao.showUuid) &&
dao.showUuid !== entity.showUuid) ||
(entity.type === 'album' &&
isDefined(dao.artistUuid) &&
dao.artistUuid !== entity.artistUuid);
const differentVersion = entity.canonicalId !== dao.canonicalId;
shouldUpdate ||= differentVersion || missingAssociation;
}
}
if (entity && shouldUpdate) {
newGroupingAndRelations.programGrouping.uuid = entity.uuid;
for (const externalId of newGroupingAndRelations.externalIds) {
externalId.groupUuid = entity.uuid;
}
entity = await this.drizzleDB.transaction(async (tx) => {
const updated = await this.updateProgramGrouping(
newGroupingAndRelations,
entity!,
tx,
);
const upsertedExternalIds = await this.updateProgramGroupingExternalIds(
entity!.externalIds,
externalIds,
tx,
);
return {
...updated,
externalIds: upsertedExternalIds,
} satisfies ProgramGroupingOrmWithRelations;
});
wasUpdated = true;
} else if (!entity) {
entity = await this.drizzleDB.transaction(async (tx) => {
const grouping = head(
await tx
.insert(ProgramGrouping)
.values(omit(dao, 'externalIds'))
.returning(),
)!;
const insertedExternalIds: ProgramGroupingExternalIdOrm[] = [];
if (externalIds.length > 0) {
insertedExternalIds.push(
...(await this.upsertProgramGroupingExternalIdsChunkOrm(
externalIds,
tx,
)),
);
}
return {
...grouping,
externalIds: insertedExternalIds,
} satisfies ProgramGroupingOrmWithRelations;
});
wasInserted = true;
shouldUpdate = true;
}
if (shouldUpdate) {
newGroupingAndRelations.credits.forEach((credit) => {
credit.credit.groupingId = entity.uuid;
});
newGroupingAndRelations.artwork.forEach((artwork) => {
artwork.groupingId = entity.uuid;
});
await this.metadataRepo.upsertCredits(
newGroupingAndRelations.credits.map(({ credit }) => credit),
);
await this.metadataRepo.upsertArtwork(
newGroupingAndRelations.artwork.concat(
newGroupingAndRelations.credits.flatMap(({ artwork }) => artwork),
),
);
await this.metadataRepo.upsertProgramGroupingGenres(
entity.uuid,
newGroupingAndRelations.genres,
);
await this.metadataRepo.upsertProgramGroupingStudios(
entity.uuid,
newGroupingAndRelations.studios,
);
await this.metadataRepo.upsertProgramGroupingTags(
entity.uuid,
newGroupingAndRelations.tags,
);
}
return {
entity: entity,
wasInserted,
wasUpdated,
};
}
private async getProgramGrouping(
id: string,
): Promise<ProgramGroupingOrmWithRelations | undefined> {
return this.drizzleDB.query.programGrouping.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
with: {
externalIds: true,
artwork: true,
},
});
}
private async getProgramGroupingByExternalId(
eid: ProgramGroupingExternalIdLookup,
): Promise<ProgramGroupingOrmWithRelations | undefined> {
return await this.drizzleDB.query.programGroupingExternalId
.findFirst({
where: (row, { and, or, eq }) =>
and(
eq(row.externalKey, eid.externalKey),
eq(row.sourceType, eid.sourceType),
or(
eq(row.externalSourceId, untag(eid.externalSourceId)),
eq(row.mediaSourceId, eid.externalSourceId),
),
),
with: {
grouping: {
with: {
externalIds: true,
},
},
},
})
.then((result) => result?.grouping ?? undefined);
}
private async updateProgramGrouping(
{ programGrouping: incoming }: NewProgramGroupingWithRelations,
existing: ProgramGroupingOrmWithRelations,
tx: BaseSQLiteDatabase<'sync', RunResult, typeof schema> = this.drizzleDB,
): Promise<ProgramGroupingOrm> {
const update: NewProgramGroupingOrm = {
...omit(existing, 'externalIds'),
index: incoming.index,
title: incoming.title,
summary: incoming.summary,
icon: incoming.icon,
year: incoming.year,
artistUuid: incoming.artistUuid,
showUuid: incoming.showUuid,
canonicalId: incoming.canonicalId,
mediaSourceId: incoming.mediaSourceId,
libraryId: incoming.libraryId,
sourceType: incoming.sourceType,
externalKey: incoming.externalKey,
plot: incoming.plot,
rating: incoming.rating,
releaseDate: incoming.releaseDate,
tagline: incoming.tagline,
updatedAt: incoming.updatedAt,
state: incoming.state,
};
return head(
await tx
.update(ProgramGrouping)
.set(update)
.where(eq(ProgramGrouping.uuid, existing.uuid))
.limit(1)
.returning(),
)!;
}
private async updateProgramGroupingExternalIds(
existingIds: ProgramGroupingExternalId[],
newIds: NewSingleOrMultiProgramGroupingExternalId[],
tx: BaseSQLiteDatabase<'sync', RunResult, typeof schema> = this.drizzleDB,
): Promise<ProgramGroupingExternalIdOrm[]> {
devAssert(
uniq(seq.collect(existingIds, (id) => id.mediaSourceId)).length <= 1,
);
devAssert(uniq(existingIds.map((id) => id.libraryId)).length <= 1);
devAssert(uniq(newIds.map((id) => id.libraryId)).length <= 1);
const newByUniqueId: Record<
string,
NewSingleOrMultiProgramGroupingExternalId
> = groupByUniq(newIds, (id) => {
switch (id.type) {
case 'single':
return id.sourceType;
case 'multi':
return `${id.sourceType}|${id.mediaSourceId}`;
}
});
const newUniqueIds = new Set(keys(newByUniqueId));
const existingByUniqueId: Record<string, ProgramGroupingExternalId> =
groupByUniq(existingIds, (id) => {
if (isValidSingleExternalIdType(id.sourceType)) {
return id.sourceType;
} else {
return `${id.sourceType}|${id.mediaSourceId}`;
}
});
const existingUniqueIds = new Set(keys(existingByUniqueId));
const deletedUniqueKeys = existingUniqueIds.difference(newUniqueIds);
const addedUniqueKeys = newUniqueIds.difference(existingUniqueIds);
const updatedKeys = existingUniqueIds.intersection(newUniqueIds);
const deletedIds = [...deletedUniqueKeys.values()].map(
(key) => existingByUniqueId[key]!,
);
await Promise.all(
chunk(deletedIds, 100).map((idChunk) => {
const clauses = idChunk.map((id) =>
and(
id.mediaSourceId
? eq(ProgramGroupingExternalId.mediaSourceId, id.mediaSourceId)
: dbIsNull(ProgramGroupingExternalId.mediaSourceId),
id.libraryId
? eq(ProgramGroupingExternalId.libraryId, id.libraryId)
: dbIsNull(ProgramGroupingExternalId.libraryId),
eq(ProgramGroupingExternalId.externalKey, id.externalKey),
id.externalSourceId
? eq(
ProgramGroupingExternalId.externalSourceId,
id.externalSourceId,
)
: dbIsNull(ProgramGroupingExternalId.externalSourceId),
eq(ProgramGroupingExternalId.sourceType, id.sourceType),
),
);
return tx
.delete(ProgramGroupingExternalId)
.where(or(...clauses))
.execute();
}),
);
const addedIds = [...addedUniqueKeys.union(updatedKeys).values()].map(
(key) => newByUniqueId[key]!,
);
return await Promise.all(
chunk(addedIds, 100).map((idChunk) =>
this.upsertProgramGroupingExternalIdsChunkOrm(idChunk, tx),
),
).then((_) => _.flat());
}
async upsertProgramGroupingExternalIdsChunkOrm(
ids: (
| NewSingleOrMultiProgramGroupingExternalId
| NewProgramGroupingExternalId
)[],
tx: BaseSQLiteDatabase<'sync', RunResult, typeof schema> = this.drizzleDB,
): Promise<ProgramGroupingExternalIdOrm[]> {
if (ids.length === 0) {
return [];
}
const [singles, multiples] = partition(ids, (id) =>
isValidSingleExternalIdType(id.sourceType),
);
const promises: Promise<ProgramGroupingExternalIdOrm[]>[] = [];
if (singles.length > 0) {
promises.push(
tx
.insert(ProgramGroupingExternalId)
.values(singles.map(toInsertableProgramGroupingExternalId))
.onConflictDoUpdate({
target: [
ProgramGroupingExternalId.groupUuid,
ProgramGroupingExternalId.sourceType,
],
targetWhere: sql`media_source_id is null`,
set: {
updatedAt: sql`excluded.updated_at`,
externalFilePath: sql`excluded.external_file_path`,
groupUuid: sql`excluded.group_uuid`,
externalKey: sql`excluded.external_key`,
},
})
.returning()
.execute(),
);
}
if (multiples.length > 0) {
promises.push(
tx
.insert(ProgramGroupingExternalId)
.values(multiples.map(toInsertableProgramGroupingExternalId))
.onConflictDoUpdate({
target: [
ProgramGroupingExternalId.groupUuid,
ProgramGroupingExternalId.sourceType,
ProgramGroupingExternalId.mediaSourceId,
],
targetWhere: sql`media_source_id is not null`,
set: {
updatedAt: sql`excluded.updated_at`,
externalFilePath: sql`excluded.external_file_path`,
groupUuid: sql`excluded.group_uuid`,
externalKey: sql`excluded.external_key`,
},
})
.returning()
.execute(),
);
}
return (await Promise.all(promises)).flat();
}
async upsertProgramGroupingExternalIdsChunk(
ids: (
| NewSingleOrMultiProgramGroupingExternalId
| NewProgramGroupingExternalId
)[],
tx: Kysely<DB> = this.db,
): Promise<void> {
if (ids.length === 0) {
return;
}
const [singles, multiples] = partition(ids, (id) =>
isValidSingleExternalIdType(id.sourceType),
);
const promises: Promise<InsertResult>[] = [];
if (singles.length > 0) {
promises.push(
tx
.insertInto('programGroupingExternalId')
.values(singles.map(toInsertableProgramGroupingExternalId))
.onConflict((oc) =>
oc
.columns(['groupUuid', 'sourceType'])
.where('mediaSourceId', 'is', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
groupUuid: eb.ref('excluded.groupUuid'),
externalKey: eb.ref('excluded.externalKey'),
})),
)
.executeTakeFirstOrThrow(),
);
}
if (multiples.length > 0) {
promises.push(
tx
.insertInto('programGroupingExternalId')
.values(multiples.map(toInsertableProgramGroupingExternalId))
.onConflict((oc) =>
oc
.columns(['groupUuid', 'sourceType', 'mediaSourceId'])
.where('mediaSourceId', 'is not', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
groupUuid: eb.ref('excluded.groupUuid'),
externalKey: eb.ref('excluded.externalKey'),
})),
)
.executeTakeFirstOrThrow(),
);
}
await Promise.all(promises);
}
}

View File

@@ -0,0 +1,444 @@
import { KEYS } from '@/types/inject.js';
import { seq } from '@tunarr/shared/util';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import { chunk, difference, groupBy, isNil, keys, partition } from 'lodash-es';
import type { Dictionary } from 'ts-essentials';
import { groupByUniq, isDefined, isNonEmptyString } from '../../util/index.ts';
import { Artwork, type NewArtwork } from '../schema/Artwork.ts';
import { Credit, type NewCredit } from '../schema/Credit.ts';
import {
EntityGenre,
Genre,
type NewGenre,
type NewGenreEntity,
} from '../schema/Genre.ts';
import {
NewProgramSubtitles,
ProgramSubtitles,
} from '../schema/ProgramSubtitles.ts';
import {
NewStudio,
NewStudioEntity,
Studio,
StudioEntity,
} from '../schema/Studio.ts';
import { NewTag, NewTagRelation, Tag, TagRelations } from '../schema/Tag.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
@injectable()
export class ProgramMetadataRepository {
constructor(@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess) {}
async upsertArtwork(artwork: NewArtwork[]) {
if (artwork.length === 0) {
return;
}
const programArt = groupBy(
artwork.filter((art) => isNonEmptyString(art.programId)),
(art) => art.programId,
);
const groupArt = groupBy(
artwork.filter((art) => isNonEmptyString(art.groupingId)),
(art) => art.groupingId,
);
const creditArt = groupBy(
artwork.filter((art) => isNonEmptyString(art.creditId)),
(art) => art.creditId,
);
return await this.drizzleDB.transaction(async (tx) => {
for (const batch of chunk(keys(programArt), 50)) {
await tx.delete(Artwork).where(inArray(Artwork.programId, batch));
}
for (const batch of chunk(keys(groupArt), 50)) {
await tx.delete(Artwork).where(inArray(Artwork.groupingId, batch));
}
for (const batch of chunk(keys(creditArt), 50)) {
await tx.delete(Artwork).where(inArray(Artwork.creditId, batch));
}
const inserted: Artwork[] = [];
for (const batch of chunk(artwork, 50)) {
const batchResult = await this.drizzleDB
.insert(Artwork)
.values(batch)
.onConflictDoUpdate({
target: Artwork.uuid,
set: {
cachePath: sql`excluded.cache_path`,
groupingId: sql`excluded.grouping_id`,
programId: sql`excluded.program_id`,
updatedAt: sql`excluded.updated_at`,
sourcePath: sql`excluded.source_path`,
},
})
.returning();
inserted.push(...batchResult);
}
return inserted;
});
}
async upsertProgramGenres(programId: string, genres: NewGenre[]) {
return this.upsertProgramGenresInternal('program', programId, genres);
}
async upsertProgramGroupingGenres(groupingId: string, genres: NewGenre[]) {
return this.upsertProgramGenresInternal('grouping', groupingId, genres);
}
private async upsertProgramGenresInternal(
entityType: 'program' | 'grouping',
joinId: string,
genres: NewGenre[],
) {
if (genres.length === 0) {
return;
}
const incomingByName = groupByUniq(genres, (g) => g.name);
const existingGenresByName: Dictionary<Genre> = {};
for (const genreChunk of chunk(genres, 100)) {
const names = genreChunk.map((g) => g.name);
const results = await this.drizzleDB
.select()
.from(Genre)
.where(inArray(Genre.name, names));
for (const result of results) {
existingGenresByName[result.name] = result;
}
}
const newGenreNames = new Set(
difference(keys(incomingByName), keys(existingGenresByName)),
);
const relations: NewGenreEntity[] = [];
for (const name of Object.keys(incomingByName)) {
const genreId = newGenreNames.has(name)
? incomingByName[name]!.uuid
: existingGenresByName[name]!.uuid;
relations.push({
genreId,
programId: entityType === 'program' ? joinId : null,
groupId: entityType === 'grouping' ? joinId : null,
});
}
return this.drizzleDB.transaction(async (tx) => {
const col =
entityType === 'grouping' ? EntityGenre.groupId : EntityGenre.programId;
await tx.delete(EntityGenre).where(eq(col, joinId));
if (newGenreNames.size > 0) {
await tx
.insert(Genre)
.values(
[...newGenreNames.values()].map((name) => incomingByName[name]!),
)
.onConflictDoNothing();
}
if (relations.length > 0) {
await tx.insert(EntityGenre).values(relations).onConflictDoNothing();
}
});
}
async upsertProgramStudios(programId: string, studios: NewStudio[]) {
return this.upsertProgramStudiosInternal('program', programId, studios);
}
async upsertProgramGroupingStudios(groupingId: string, studios: NewStudio[]) {
return this.upsertProgramStudiosInternal('grouping', groupingId, studios);
}
private async upsertProgramStudiosInternal(
entityType: 'program' | 'grouping',
joinId: string,
studios: NewStudio[],
) {
if (studios.length === 0) {
return;
}
const incomingByName = groupByUniq(studios, (g) => g.name);
const existingStudiosByName: Dictionary<Studio> = {};
for (const studioChunk of chunk(studios, 100)) {
const names = studioChunk.map((g) => g.name);
const results = await this.drizzleDB
.select()
.from(Studio)
.where(inArray(Studio.name, names));
for (const result of results) {
existingStudiosByName[result.name] = result;
}
}
const newStudioNames = new Set(
difference(keys(incomingByName), keys(existingStudiosByName)),
);
const relations: NewStudioEntity[] = [];
for (const name of Object.keys(incomingByName)) {
const studioId = newStudioNames.has(name)
? incomingByName[name]!.uuid
: existingStudiosByName[name]!.uuid;
relations.push({
studioId,
programId: entityType === 'program' ? joinId : null,
groupId: entityType === 'grouping' ? joinId : null,
});
}
return this.drizzleDB.transaction(async (tx) => {
const col =
entityType === 'grouping'
? StudioEntity.groupId
: StudioEntity.programId;
await tx.delete(StudioEntity).where(eq(col, joinId));
if (newStudioNames.size > 0) {
await tx
.insert(Studio)
.values(
[...newStudioNames.values()].map((name) => incomingByName[name]!),
)
.onConflictDoNothing();
}
if (relations.length > 0) {
await tx.insert(StudioEntity).values(relations).onConflictDoNothing();
}
});
}
async upsertProgramTags(programId: string, tags: NewTag[]) {
return this.upsertProgramTagsInternal('program', programId, tags);
}
async upsertProgramGroupingTags(groupingId: string, tags: NewTag[]) {
return this.upsertProgramTagsInternal('grouping', groupingId, tags);
}
private async upsertProgramTagsInternal(
entityType: 'program' | 'grouping',
joinId: string,
tags: NewTag[],
) {
if (tags.length === 0) {
return;
}
const incomingByName = groupByUniq(tags, (g) => g.tag);
const existingTagsByName: Dictionary<Tag> = {};
for (const tagChunk of chunk(tags, 100)) {
const names = tagChunk.map((g) => g.tag);
const results = await this.drizzleDB
.select()
.from(Tag)
.where(inArray(Tag.tag, names));
for (const result of results) {
existingTagsByName[result.tag] = result;
}
}
const newTagNames = new Set(
difference(keys(incomingByName), keys(existingTagsByName)),
);
const relations: NewTagRelation[] = [];
for (const name of Object.keys(incomingByName)) {
const tagId = newTagNames.has(name)
? incomingByName[name]!.uuid
: existingTagsByName[name]!.uuid;
relations.push({
tagId,
programId: entityType === 'program' ? joinId : null,
groupingId: entityType === 'grouping' ? joinId : null,
source: 'media',
});
}
return this.drizzleDB.transaction(async (tx) => {
const col =
entityType === 'grouping'
? TagRelations.groupingId
: TagRelations.programId;
await tx
.delete(TagRelations)
.where(and(eq(col, joinId), eq(TagRelations.source, 'media')));
if (newTagNames.size > 0) {
await tx
.insert(Tag)
.values(
[...newTagNames.values()].map((name) => incomingByName[name]!),
)
.onConflictDoNothing();
}
if (relations.length > 0) {
await tx.insert(TagRelations).values(relations).onConflictDoNothing();
}
});
}
async upsertSubtitles(subtitles: NewProgramSubtitles[]) {
if (subtitles.length === 0) {
return;
}
const grouped = groupBy(subtitles, (sub) => sub.programId);
for (const [programId, programSubtitles] of Object.entries(grouped)) {
const existingSubsForProgram =
await this.drizzleDB.query.programSubtitles.findMany({
where: (fields, { eq }) => eq(fields.programId, programId),
});
const [existingEmbedded, _] = partition(
existingSubsForProgram,
(sub) => !isNil(sub.streamIndex),
);
const [incomingEmbedded, incomingExternal] = partition(
programSubtitles,
(sub) => !isNil(sub.streamIndex),
);
const existingIndexes = new Set(
seq.collect(existingEmbedded, (sub) => sub.streamIndex),
);
const incomingIndexes = new Set(
seq.collect(incomingEmbedded, (sub) => sub.streamIndex),
);
const newIndexes = incomingIndexes.difference(existingIndexes);
const removedIndexes = existingIndexes.difference(newIndexes);
const updatedIndexes = incomingIndexes.difference(
newIndexes.union(removedIndexes),
);
const inserts = incomingEmbedded.filter((s) =>
newIndexes.has(s.streamIndex!),
);
const removes = existingEmbedded.filter((s) =>
removedIndexes.has(s.streamIndex!),
);
const updates: ProgramSubtitles[] = [];
for (const updatedIndex of updatedIndexes.values()) {
const incoming = incomingEmbedded.find(
(s) => s.streamIndex === updatedIndex,
);
const existing = existingEmbedded.find(
(s) => s.streamIndex === updatedIndex,
);
if (!existing || !incoming) {
continue;
}
if (existing.isExtracted) {
const needsExtraction =
existing.subtitleType !== incoming.subtitleType ||
existing.codec !== incoming.subtitleType ||
existing.language !== incoming.language ||
existing.forced !== incoming.forced ||
existing.sdh !== incoming.sdh ||
existing.default !== incoming.default;
if (needsExtraction) {
existing.isExtracted = false;
existing.path = incoming.path ?? null;
} else if (
isNonEmptyString(incoming.path) &&
existing.path !== incoming.path
) {
existing.isExtracted = false;
existing.path = incoming.path;
}
}
existing.codec = incoming.codec;
existing.language = incoming.language;
existing.subtitleType = incoming.subtitleType;
existing.updatedAt = incoming.updatedAt;
if (isDefined(incoming.default)) {
existing.default = incoming.default;
}
if (isDefined(incoming.sdh)) {
existing.sdh = incoming.sdh;
}
if (isDefined(incoming.forced)) {
existing.forced = incoming.forced;
}
updates.push(existing);
}
await this.drizzleDB.transaction(async (tx) => {
if (inserts.length > 0) {
await tx.insert(ProgramSubtitles).values(inserts);
}
if (removes.length > 0) {
await tx.delete(ProgramSubtitles).where(
inArray(
ProgramSubtitles.uuid,
removes.map((s) => s.uuid),
),
);
}
if (updates.length > 0) {
for (const update of updates) {
await tx
.update(ProgramSubtitles)
.set(update)
.where(eq(ProgramSubtitles.uuid, update.uuid));
}
}
await tx
.delete(ProgramSubtitles)
.where(
and(
eq(ProgramSubtitles.subtitleType, 'sidecar'),
eq(ProgramSubtitles.programId, programId),
),
);
if (incomingExternal.length > 0) {
await tx.insert(ProgramSubtitles).values(incomingExternal);
}
});
}
}
async upsertCredits(credits: NewCredit[]) {
if (credits.length === 0) {
return;
}
const programCredits = groupBy(
credits.filter((credit) => isNonEmptyString(credit.programId)),
(credit) => credit.programId,
);
const groupCredits = groupBy(
credits.filter((credit) => isNonEmptyString(credit.groupingId)),
(credit) => credit.groupingId,
);
return await this.drizzleDB.transaction(async (tx) => {
for (const batch of chunk(keys(programCredits), 50)) {
await tx.delete(Credit).where(inArray(Credit.programId, batch));
}
for (const batch of chunk(keys(groupCredits), 50)) {
await tx.delete(Credit).where(inArray(Credit.groupingId, batch));
}
const inserted: Credit[] = [];
for (const batch of chunk(credits, 50)) {
const batchResult = await this.drizzleDB
.insert(Credit)
.values(batch)
.returning();
inserted.push(...batchResult);
}
return inserted;
});
}
}

View File

@@ -0,0 +1,229 @@
import type {
ProgramCanonicalIdLookupResult,
ProgramGroupingCanonicalIdLookupResult,
} from '@/db/interfaces/IProgramDB.js';
import { KEYS } from '@/types/inject.js';
import { inject, injectable } from 'inversify';
import type { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
import { last } from 'lodash-es';
import type { Dictionary, StrictExclude } from 'ts-essentials';
import { match } from 'ts-pattern';
import {
AllProgramFields,
selectProgramsBuilder,
withProgramExternalIds,
} from '../programQueryHelpers.ts';
import type { ProgramType } from '../schema/Program.ts';
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
import type { MediaSourceId, MediaSourceType } from '../schema/base.ts';
import type { DB } from '../schema/db.ts';
import type { ProgramWithRelations } from '../schema/derivedTypes.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
import { isDefined } from '../../util/index.ts';
import type { ProgramDao } from '../schema/Program.ts';
@injectable()
export class ProgramSearchRepository {
constructor(
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {}
async getProgramsForMediaSource(
mediaSourceId: MediaSourceId,
type?: ProgramType,
): Promise<ProgramDao[]> {
return this.db
.selectFrom('mediaSource')
.where('mediaSource.uuid', '=', mediaSourceId)
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('program')
.select(AllProgramFields)
.$if(isDefined(type), (eb) => eb.where('program.type', '=', type!))
.whereRef('mediaSource.uuid', '=', 'program.mediaSourceId'),
).as('programs'),
)
.executeTakeFirst()
.then((dbResult) => dbResult?.programs ?? []);
}
async getMediaSourceLibraryPrograms(
libraryId: string,
): Promise<ProgramWithRelations[]> {
return selectProgramsBuilder(this.db, { includeGroupingExternalIds: true })
.where('libraryId', '=', libraryId)
.selectAll()
.select(withProgramExternalIds)
.execute();
}
async getProgramInfoForMediaSource(
mediaSourceId: MediaSourceId,
type: ProgramType,
parentFilter?: [ProgramGroupingType, string],
): Promise<Dictionary<ProgramCanonicalIdLookupResult>> {
const results = await this.drizzleDB.query.program.findMany({
where: (fields, { eq, and, isNotNull }) => {
const parentField = match([type, parentFilter?.[0]])
.with(['episode', 'show'], () => fields.tvShowUuid)
.with(['episode', 'season'], () => fields.seasonUuid)
.with(['track', 'album'], () => fields.albumUuid)
.with(['track', 'artist'], () => fields.artistUuid)
.otherwise(() => null);
return and(
eq(fields.mediaSourceId, mediaSourceId),
eq(fields.type, type),
isNotNull(fields.canonicalId),
parentField && parentFilter
? eq(parentField, parentFilter[1])
: undefined,
);
},
});
const grouped: Dictionary<ProgramCanonicalIdLookupResult> = {};
for (const result of results) {
if (!result.canonicalId || !result.libraryId) {
continue;
}
grouped[result.externalKey] = {
canonicalId: result.canonicalId,
externalKey: result.externalKey,
libraryId: result.libraryId,
uuid: result.uuid,
};
}
return grouped;
}
async getProgramInfoForMediaSourceLibrary(
mediaSourceLibraryId: string,
type: ProgramType,
parentFilter?: [ProgramGroupingType, string],
): Promise<Dictionary<ProgramCanonicalIdLookupResult>> {
const grouped: Dictionary<ProgramCanonicalIdLookupResult> = {};
for await (const result of this.getProgramInfoForMediaSourceLibraryAsync(
mediaSourceLibraryId,
type,
parentFilter,
)) {
grouped[result.externalKey] = {
canonicalId: result.canonicalId,
externalKey: result.externalKey,
libraryId: result.libraryId,
uuid: result.uuid,
};
}
return grouped;
}
async *getProgramInfoForMediaSourceLibraryAsync(
mediaSourceLibraryId: string,
type: ProgramType,
parentFilter?: [ProgramGroupingType, string],
): AsyncGenerator<ProgramCanonicalIdLookupResult> {
let lastId: string | undefined;
for (;;) {
const page = await this.drizzleDB.query.program.findMany({
where: (fields, { eq, and, isNotNull, gt }) => {
const parentField = match([type, parentFilter?.[0]])
.with(['episode', 'show'], () => fields.tvShowUuid)
.with(['episode', 'season'], () => fields.seasonUuid)
.with(['track', 'album'], () => fields.albumUuid)
.with(['track', 'artist'], () => fields.artistUuid)
.otherwise(() => null);
return and(
eq(fields.libraryId, mediaSourceLibraryId),
eq(fields.type, type),
isNotNull(fields.canonicalId),
parentField && parentFilter
? eq(parentField, parentFilter[1])
: undefined,
lastId ? gt(fields.uuid, lastId) : undefined,
);
},
orderBy: (fields, ops) => ops.asc(fields.uuid),
columns: {
uuid: true,
canonicalId: true,
libraryId: true,
externalKey: true,
},
limit: 500,
});
if (page.length === 0) {
return;
}
lastId = last(page)?.uuid;
for (const item of page) {
yield {
externalKey: item.externalKey,
canonicalId: item.canonicalId,
uuid: item.uuid,
libraryId: item.libraryId,
};
}
}
}
async getExistingProgramGroupingDetails(
mediaSourceLibraryId: string,
type: ProgramGroupingType,
sourceType: StrictExclude<MediaSourceType, 'local'>,
parentFilter?: string,
): Promise<Dictionary<ProgramGroupingCanonicalIdLookupResult>> {
const results = await this.drizzleDB.query.programGrouping.findMany({
where: (fields, { and, eq, isNotNull }) => {
const parentField = match(type)
.with('album', () => fields.artistUuid)
.with('season', () => fields.showUuid)
.otherwise(() => null);
return and(
eq(fields.libraryId, mediaSourceLibraryId),
eq(fields.type, type),
isNotNull(fields.canonicalId),
parentField && parentFilter
? eq(parentField, parentFilter)
: undefined,
);
},
with: {
externalIds: {
where: (fields, { eq }) => eq(fields.sourceType, sourceType),
},
},
columns: {
uuid: true,
canonicalId: true,
libraryId: true,
externalKey: true,
},
});
const grouped: Dictionary<ProgramGroupingCanonicalIdLookupResult> = {};
for (const result of results) {
const key = result.externalKey ?? result.externalIds[0]?.externalKey;
if (!key) {
continue;
}
grouped[key] = {
canonicalId: result.canonicalId,
externalKey: key,
libraryId: result.libraryId!,
uuid: result.uuid,
};
}
return grouped;
}
}

View File

@@ -0,0 +1,57 @@
import { KEYS } from '@/types/inject.js';
import { eq, inArray } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import { chunk } from 'lodash-es';
import { Program } from '../schema/Program.ts';
import { ProgramGrouping } from '../schema/ProgramGrouping.ts';
import type { ProgramState } from '../schema/base.ts';
import type { DrizzleDBAccess } from '../schema/index.ts';
@injectable()
export class ProgramStateRepository {
constructor(
@inject(KEYS.DrizzleDB) private drizzleDB: DrizzleDBAccess,
) {}
async updateProgramsState(
programIds: string[],
newState: ProgramState,
): Promise<void> {
if (programIds.length === 0) {
return;
}
for (const idChunk of chunk(programIds, 100)) {
await this.drizzleDB
.update(Program)
.set({
state: newState,
})
.where(inArray(Program.uuid, idChunk))
.execute();
}
}
async updateGroupingsState(
groupingIds: string[],
newState: ProgramState,
): Promise<void> {
if (groupingIds.length === 0) {
return;
}
for (const idChunk of chunk(groupingIds, 100)) {
await this.drizzleDB
.update(ProgramGrouping)
.set({
state: newState,
})
.where(inArray(ProgramGrouping.uuid, idChunk))
.execute();
}
}
async emptyTrashPrograms(): Promise<void> {
await this.drizzleDB.delete(Program).where(eq(Program.state, 'missing'));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,22 @@ const KEYS = {
// Tasks
Task: Symbol.for('Task'),
StartupTasks: Symbol.for('StartupTasks'),
// ProgramDB repositories
BasicProgramRepository: Symbol.for('BasicProgramRepository'),
ProgramGroupingRepository: Symbol.for('ProgramGroupingRepository'),
ProgramExternalIdRepository: Symbol.for('ProgramExternalIdRepository'),
ProgramUpsertRepository: Symbol.for('ProgramUpsertRepository'),
ProgramMetadataRepository: Symbol.for('ProgramMetadataRepository'),
ProgramGroupingUpsertRepository: Symbol.for('ProgramGroupingUpsertRepository'),
ProgramSearchRepository: Symbol.for('ProgramSearchRepository'),
ProgramStateRepository: Symbol.for('ProgramStateRepository'),
// ChannelDB repositories
BasicChannelRepository: Symbol.for('BasicChannelRepository'),
ChannelProgramRepository: Symbol.for('ChannelProgramRepository'),
LineupRepository: Symbol.for('LineupRepository'),
ChannelConfigRepository: Symbol.for('ChannelConfigRepository'),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any