mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
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.
348 lines
9.9 KiB
TypeScript
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];
|
|
}
|
|
}
|