mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
refactor: break up DB classes into focused repositories
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
532
server/src/db/channel/BasicChannelRepository.ts
Normal file
532
server/src/db/channel/BasicChannelRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
server/src/db/channel/ChannelConfigRepository.ts
Normal file
23
server/src/db/channel/ChannelConfigRepository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
478
server/src/db/channel/ChannelProgramRepository.ts
Normal file
478
server/src/db/channel/ChannelProgramRepository.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
1225
server/src/db/channel/LineupRepository.ts
Normal file
1225
server/src/db/channel/LineupRepository.ts
Normal file
File diff suppressed because it is too large
Load Diff
114
server/src/db/program/BasicProgramRepository.ts
Normal file
114
server/src/db/program/BasicProgramRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
375
server/src/db/program/ProgramExternalIdRepository.ts
Normal file
375
server/src/db/program/ProgramExternalIdRepository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
610
server/src/db/program/ProgramGroupingRepository.ts
Normal file
610
server/src/db/program/ProgramGroupingRepository.ts
Normal 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
494
server/src/db/program/ProgramGroupingUpsertRepository.ts
Normal file
494
server/src/db/program/ProgramGroupingUpsertRepository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
444
server/src/db/program/ProgramMetadataRepository.ts
Normal file
444
server/src/db/program/ProgramMetadataRepository.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
229
server/src/db/program/ProgramSearchRepository.ts
Normal file
229
server/src/db/program/ProgramSearchRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
57
server/src/db/program/ProgramStateRepository.ts
Normal file
57
server/src/db/program/ProgramStateRepository.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
1080
server/src/db/program/ProgramUpsertRepository.ts
Normal file
1080
server/src/db/program/ProgramUpsertRepository.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user