Files
tunarr/server/src/db/mediaSourceDB.ts
Christian Benincasa 769b05d201 refactor: add media_source_id to relevant entities (#1106)
We should be referencing media_sources by their ID on programs,
external_ids, etc. This enables us to use proper foreign keys for
referential integrity at the DB level, not worry about unique names for
media sources, and simplifies a lot of the code relating to media source
deletion and the cleanup thereafter.

This change also introduces the DBContext, which should allow for
arbitrarily calling other DB accessor functions when within transactions
and not deadlocking the connection to the DB.
2025-02-28 15:53:29 -05:00

348 lines
9.9 KiB
TypeScript

import { Maybe } from '@/types/util.js';
import { groupByUniqProp, isNonEmptyString } from '@/util/index.js';
import type {
InsertMediaSourceRequest,
UpdateMediaSourceRequest,
} from '@tunarr/types/api';
import dayjs from 'dayjs';
import {
chunk,
first,
isNil,
isUndefined,
keys,
map,
mapValues,
some,
trimEnd,
} from 'lodash-es';
import { v4 } from 'uuid';
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
import { KEYS } from '@/types/inject.js';
import { booleanToNumber } from '@/util/sqliteUtil.js';
import { inject, injectable } from 'inversify';
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
import { getDatabase } from './DBAccess.ts';
import {
withProgramChannels,
withProgramCustomShows,
withProgramFillerShows,
} from './programQueryHelpers.ts';
import { MediaSource, MediaSourceType } from './schema/MediaSource.ts';
type Report = {
type: 'channel' | 'custom-show' | 'filler';
id: string;
channelNumber?: number;
channelName?: string;
destroyedPrograms: number;
modifiedPrograms: number;
};
@injectable()
export class MediaSourceDB {
constructor(
@inject(KEYS.ChannelDB) private channelDb: IChannelDB,
@inject(KEYS.MediaSourceApiFactory)
private mediaSourceApiFactory: () => MediaSourceApiFactory,
) {}
async getAll(): Promise<MediaSource[]> {
return getDatabase().selectFrom('mediaSource').selectAll().execute();
}
async getById(id: string) {
return getDatabase()
.selectFrom('mediaSource')
.selectAll()
.where('mediaSource.uuid', '=', id)
.executeTakeFirst();
}
async getByName(name: string) {
return getDatabase()
.selectFrom('mediaSource')
.selectAll()
.where('mediaSource.name', '=', name)
.executeTakeFirst();
}
async findByType(
type: MediaSourceType,
nameOrId: string,
): Promise<MediaSource | undefined>;
async findByType(type: MediaSourceType): Promise<MediaSource[]>;
async findByType(
type: MediaSourceType,
nameOrId?: string,
): Promise<MediaSource[] | Maybe<MediaSource>> {
const found = await getDatabase()
.selectFrom('mediaSource')
.selectAll()
.where('mediaSource.type', '=', type)
.$if(isNonEmptyString(nameOrId), (qb) =>
qb.where((eb) =>
eb.or([
eb('mediaSource.name', '=', nameOrId!),
eb('mediaSource.uuid', '=', nameOrId!),
]),
),
)
.execute();
if (isNonEmptyString(nameOrId)) {
return first(found);
} else {
return found;
}
}
async getByExternalId(
sourceType: MediaSourceType,
nameOrClientId: string,
): Promise<Maybe<MediaSource>> {
return getDatabase()
.selectFrom('mediaSource')
.selectAll()
.where((eb) =>
eb.and([
eb('type', '=', sourceType),
eb.or([
eb('name', '=', nameOrClientId),
eb('clientIdentifier', '=', nameOrClientId),
]),
]),
)
.executeTakeFirst();
}
async deleteMediaSource(id: string) {
const deletedServer = await this.getById(id);
if (isNil(deletedServer)) {
throw new Error(`MediaSource not found: ${id}`);
}
// This should cascade all relevant deletes across the DB
await getDatabase()
.transaction()
.execute(async (tx) => {
const relatedProgramIds = await tx
.selectFrom('program')
.where('program.mediaSourceId', '=', id)
.select('uuid')
.execute()
.then((_) => _.map(({ uuid }) => uuid));
await tx
.deleteFrom('mediaSource')
.where('uuid', '=', id)
.limit(1)
.execute();
// TODO: Update lineups
await this.channelDb.removeProgramsFromAllLineups(relatedProgramIds);
});
this.mediaSourceApiFactory().deleteCachedClient(deletedServer);
return { deletedServer };
}
async updateMediaSource(server: UpdateMediaSourceRequest) {
const id = server.id;
const mediaSource = await this.getById(id);
if (isNil(mediaSource)) {
throw new Error("Server doesn't exist.");
}
const sendGuideUpdates =
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates =
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
await getDatabase()
.updateTable('mediaSource')
.set({
name: server.name,
uri: trimEnd(server.uri, '/'),
accessToken: server.accessToken,
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
sendChannelUpdates: booleanToNumber(sendChannelUpdates),
updatedAt: +dayjs(),
})
.where('uuid', '=', server.id)
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
// .limit(1)
.executeTakeFirst();
this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
const report = await this.fixupProgramReferences(
id,
mediaSource.type,
mediaSource,
);
return report;
}
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
const name = isUndefined(server.name) ? 'plex' : server.name;
const sendGuideUpdates =
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates =
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
const index = await getDatabase()
.selectFrom('mediaSource')
.select((eb) => eb.fn.count<number>('uuid').as('count'))
.executeTakeFirst()
.then((_) => _?.count ?? 0);
const now = +dayjs();
const newServer = await getDatabase()
.insertInto('mediaSource')
.values({
...server,
uuid: v4(),
name,
uri: trimEnd(server.uri, '/'),
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
sendGuideUpdates: sendGuideUpdates ? 1 : 0,
createdAt: now,
updatedAt: now,
index,
type: server.type,
})
.returning('uuid')
.executeTakeFirstOrThrow();
return newServer?.uuid;
}
private async fixupProgramReferences(
serverName: string,
serverType: MediaSourceType,
newServer?: MediaSource,
) {
// TODO: We need to update this to:
// 1. handle different source types
// 2. use program_external_id table
// 3. not delete programs if they still have another reference via
// the external id table (program that exists on 2 servers)
const allPrograms = await getDatabase()
.selectFrom('program')
.selectAll()
.where('sourceType', '=', serverType)
.where('externalSourceId', '=', serverName)
.select(withProgramChannels)
.select(withProgramFillerShows)
.select(withProgramCustomShows)
.execute();
const channelById = groupByUniqProp(
allPrograms.flatMap((p) => p.channels),
'uuid',
);
const customShowById = groupByUniqProp(
allPrograms.flatMap((p) => p.customShows),
'uuid',
);
const fillersById = groupByUniqProp(
allPrograms.flatMap((p) => p.fillerShows),
'uuid',
);
const channelToProgramCount = mapValues(
channelById,
({ uuid }) =>
allPrograms.filter((p) => some(p.channels, (f) => f.uuid === uuid))
.length,
);
const customShowToProgramCount = mapValues(
customShowById,
({ uuid }) =>
allPrograms.filter((p) => some(p.customShows, (f) => f.uuid === uuid))
.length,
);
const fillerToProgramCount = mapValues(
fillersById,
({ uuid }) =>
allPrograms.filter((p) => some(p.fillerShows, (f) => f.uuid === uuid))
.length,
);
const isUpdate = newServer && newServer.uuid !== serverName;
if (!isUpdate) {
// Remove all associations of this program
// TODO: See if we can just get this automatically with foreign keys...
await getDatabase()
.transaction()
.execute(async (tx) => {
for (const programChunk of chunk(allPrograms, 500)) {
const programIds = map(programChunk, 'uuid');
await tx
.deleteFrom('channelPrograms')
.where('channelPrograms.programUuid', 'in', programIds)
.execute();
await tx
.deleteFrom('fillerShowContent')
.where('fillerShowContent.programUuid', 'in', programIds)
.execute();
await tx
.deleteFrom('customShowContent')
.where('customShowContent.contentUuid', 'in', programIds)
.execute();
await tx
.deleteFrom('program')
.where('uuid', 'in', programIds)
.execute();
}
for (const channel of keys(channelById)) {
await this.channelDb.removeProgramsFromLineup(
channel,
map(allPrograms, 'uuid'),
);
}
});
}
const channelReports: Report[] = map(
channelById,
({ number, name }, id) => {
return {
type: 'channel',
id,
channelNumber: number,
channelName: name,
destroyedPrograms: isUpdate ? 0 : (channelToProgramCount[id] ?? 0),
modifiedPrograms: isUpdate ? (channelToProgramCount[id] ?? 0) : 0,
} as Report;
},
);
const fillerReports: Report[] = map(fillersById, ({ uuid }) => ({
type: 'filler',
id: uuid,
destroyedPrograms: isUpdate ? 0 : (fillerToProgramCount[uuid] ?? 0),
modifiedPrograms: isUpdate ? (fillerToProgramCount[uuid] ?? 0) : 0,
}));
const customShowReports: Report[] = map(customShowById, ({ uuid }) => ({
type: 'custom-show',
id: uuid,
destroyedPrograms: isUpdate ? 0 : (customShowToProgramCount[uuid] ?? 0),
modifiedPrograms: isUpdate ? (customShowToProgramCount[uuid] ?? 0) : 0,
}));
return [...channelReports, ...fillerReports, ...customShowReports];
}
}