Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
Christian Benincasa
2026-02-13 13:33:43 -05:00
40 changed files with 524 additions and 406 deletions

View File

@@ -55,7 +55,7 @@ import { MaterializeLineupCommand } from '../commands/MaterializeLineupCommand.t
import { MaterializeProgramGroupings } from '../commands/MaterializeProgramGroupings.ts';
import { MaterializeProgramsCommand } from '../commands/MaterializeProgramsCommand.ts';
import { container } from '../container.ts';
import { dbTranscodeConfigToApiSchema } from '../db/converters/transcodeConfigConverters.ts';
import { transcodeConfigOrmToDto } from '../db/converters/transcodeConfigConverters.ts';
import type { LegacyChannelAndLineup } from '../db/interfaces/IChannelDB.ts';
import type { SessionType } from '../stream/Session.ts';
import { Result } from '../types/result.ts';
@@ -680,7 +680,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
const config = await req.serverCtx.transcodeConfigDB.getChannelConfig(
req.params.id,
);
return res.send(dbTranscodeConfigToApiSchema(config));
return res.send(transcodeConfigOrmToDto(config));
},
);

View File

@@ -1,24 +1,26 @@
import { container } from '@/container.js';
import type { StreamLineupItem } from '@/db/derived_types/StreamLineup.js';
import { createOfflineStreamLineupItem } from '@/db/derived_types/StreamLineup.js';
import type { Channel } from '@/db/schema/Channel.js';
import { AllChannelTableKeys } from '@/db/schema/Channel.js';
import type { ChannelOrm } from '@/db/schema/Channel.js';
import { ProgramType } from '@/db/schema/Program.js';
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import { AllTranscodeConfigColumns } from '@/db/schema/TranscodeConfig.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
import { PlayerContext } from '@/stream/PlayerStreamContext.js';
import type { OfflineStreamFactoryType } from '@/stream/StreamModule.js';
import { KEYS } from '@/types/inject.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import dayjs from '@/util/dayjs.js';
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
import { isNumber, isUndefined, nth, random } from 'lodash-es';
import { isNumber, isUndefined, random } from 'lodash-es';
import { PassThrough } from 'node:stream';
import type { MarkRequired } from 'ts-essentials';
import { z } from 'zod/v4';
import type { ProgramWithRelationsOrm } from '../../db/schema/derivedTypes.ts';
import type {
ChannelOrmWithTranscodeConfig,
ProgramWithRelationsOrm,
} from '../../db/schema/derivedTypes.ts';
import type { ProgramStreamFactory } from '../../stream/ProgramStreamFactory.ts';
import { ChannelNotFoundError } from '../../types/errors.ts';
import type { Maybe } from '../../types/util.ts';
import { isNonEmptyString } from '../../util/index.ts';
export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
@@ -37,23 +39,15 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
},
async (req, res) => {
const channel = await req.serverCtx
.databaseFactory()
.selectFrom('channel')
.selectAll()
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('transcodeConfig')
.whereRef(
'transcodeConfig.uuid',
'=',
'channel.transcodeConfigId',
)
.select(AllTranscodeConfigColumns),
).as('transcodeConfig'),
)
.$narrowType<{ transcodeConfig: TranscodeConfig }>()
.executeTakeFirstOrThrow();
.drizzleFactory()
.query.channels.findFirst({
with: {
transcodeConfig: true,
},
});
if (!channel)
throw new Error('This debug endpoint requires at least one channel');
const stream = container.getNamed<OfflineStreamFactoryType>(
KEYS.ProgramStreamFactory,
@@ -93,29 +87,23 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
},
async (req, res) => {
const channel = await req.serverCtx
.databaseFactory()
.selectFrom('channel')
.selectAll()
.$if(isNonEmptyString(req.query.channelId), (eb) =>
eb.where('channel.uuid', '=', req.query.channelId as string),
)
.$if(isNumber(req.query.channelId), (eb) =>
eb.where('channel.number', '=', req.query.channelId as number),
)
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('transcodeConfig')
.whereRef(
'transcodeConfig.uuid',
'=',
'channel.transcodeConfigId',
)
.select(AllTranscodeConfigColumns),
).as('transcodeConfig'),
)
.$narrowType<{ transcodeConfig: TranscodeConfig }>()
.executeTakeFirstOrThrow();
.drizzleFactory()
.query.channels.findFirst({
where: (fields, { eq }) => {
if (isNonEmptyString(req.query.channelId)) {
return eq(fields.uuid, req.query.channelId);
} else if (isNumber(req.query.channelId)) {
return eq(fields.number, req.query.channelId);
}
return;
},
with: {
transcodeConfig: true,
},
});
if (!channel)
throw new ChannelNotFoundError(req.query.channelId ?? 'unknown');
const stream = container.getNamed<OfflineStreamFactoryType>(
KEYS.ProgramStreamFactory,
@@ -155,30 +143,18 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
}
const channels = await req.serverCtx
.databaseFactory()
.selectFrom('channelPrograms')
.where('programUuid', '=', program.uuid)
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('channel')
.whereRef('channel.uuid', '=', 'channelPrograms.channelUuid')
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('transcodeConfig')
.whereRef(
'transcodeConfig.uuid',
'=',
'channel.transcodeConfigId',
)
.select(AllTranscodeConfigColumns),
).as('transcodeConfig'),
)
.select(AllChannelTableKeys),
).as('channel'),
)
.execute();
.drizzleFactory()
.query.channelPrograms.findMany({
where: (fields, { eq }) => eq(fields.programUuid, program.uuid),
columns: {},
with: {
channel: {
with: {
transcodeConfig: true,
},
},
},
});
const firstChannel = channels?.[0]!.channel;
@@ -189,7 +165,7 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
const out = await initStream(
program,
firstChannel,
firstChannel.transcodeConfig!,
firstChannel.transcodeConfig,
);
return res.header('Content-Type', 'video/mp2t').send(out);
});
@@ -224,58 +200,42 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
: req.query.start;
const channels = await req.serverCtx
.databaseFactory()
.selectFrom('channelPrograms')
.where('programUuid', '=', program.uuid)
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('channel')
.whereRef('channel.uuid', '=', 'channelPrograms.channelUuid')
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('transcodeConfig')
.whereRef(
'transcodeConfig.uuid',
'=',
'channel.transcodeConfigId',
)
.select(AllTranscodeConfigColumns),
).as('transcodeConfig'),
)
.select(AllChannelTableKeys),
).as('channel'),
)
.execute();
.drizzleFactory()
.query.channelPrograms.findMany({
where: (fields, { eq }) => eq(fields.programUuid, program.uuid),
columns: {},
with: {
channel: {
with: {
transcodeConfig: true,
},
},
},
});
let firstChannel = nth(channels, 0)?.channel;
let firstChannel = channels[0]
?.channel as Maybe<ChannelOrmWithTranscodeConfig>;
if (!firstChannel) {
firstChannel = await req.serverCtx
.databaseFactory()
.selectFrom('channel')
.selectAll()
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('transcodeConfig')
.whereRef(
'transcodeConfig.uuid',
'=',
'channel.transcodeConfigId',
)
.select(AllTranscodeConfigColumns),
).as('transcodeConfig'),
)
.$narrowType<{ transcodeConfig: TranscodeConfig }>()
.executeTakeFirstOrThrow();
.drizzleFactory()
.query.channels.findFirst({
with: {
transcodeConfig: true,
},
});
}
if (!firstChannel) {
throw new Error(
'Cannot find a channel with a transcode config to use!',
);
}
const outStream = await initStream(
program,
firstChannel,
firstChannel.transcodeConfig!,
firstChannel.transcodeConfig,
startTime * 1000,
);
return res.header('Content-Type', 'video/mp2t').send(outStream);
@@ -284,8 +244,8 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
async function initStream(
program: MarkRequired<ProgramWithRelationsOrm, 'externalIds'>,
channel: Channel,
transcodeConfig: TranscodeConfig,
channel: ChannelOrm,
transcodeConfig: TranscodeConfigOrm,
startTime: number = 0,
) {
if (!program.mediaSourceId) {

View File

@@ -11,7 +11,7 @@ import {
import { isError, map } from 'lodash-es';
import { match, P } from 'ts-pattern';
import { z } from 'zod/v4';
import { dbTranscodeConfigToApiSchema } from '../db/converters/transcodeConfigConverters.ts';
import { transcodeConfigOrmToDto } from '../db/converters/transcodeConfigConverters.ts';
import { GlobalScheduler } from '../services/Scheduler.ts';
import { SubtitleExtractorTask } from '../tasks/SubtitleExtractorTask.ts';
import { TranscodeConfigNotFoundError } from '../types/errors.ts';
@@ -176,7 +176,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
},
async (req, res) => {
const configs = await req.serverCtx.transcodeConfigDB.getAll();
const apiConfigs = map(configs, dbTranscodeConfigToApiSchema);
const apiConfigs = map(configs, transcodeConfigOrmToDto);
return res.send(apiConfigs);
},
);
@@ -203,7 +203,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
return res.status(404).send();
}
return res.send(dbTranscodeConfigToApiSchema(config));
return res.send(transcodeConfigOrmToDto(config));
},
);
@@ -236,7 +236,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
.exhaustive();
}
return res.send(dbTranscodeConfigToApiSchema(copyResult.get()));
return res.send(transcodeConfigOrmToDto(copyResult.get()));
},
);
@@ -257,7 +257,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
const newConfig = await req.serverCtx.transcodeConfigDB.insertConfig(
req.body,
);
return res.status(201).send(dbTranscodeConfigToApiSchema(newConfig));
return res.status(201).send(transcodeConfigOrmToDto(newConfig));
},
);

View File

@@ -140,6 +140,7 @@ import { DB } from './schema/db.ts';
import {
ChannelOrmWithPrograms,
ChannelOrmWithRelations,
ChannelOrmWithTranscodeConfig,
ChannelWithPrograms,
ChannelWithRelations,
MusicAlbumOrm,
@@ -264,11 +265,16 @@ export class ChannelDB implements IChannelDB {
return !isNil(channel);
}
getChannelOrm(id: string | number): Promise<Maybe<ChannelOrm>> {
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,
},
});
}
@@ -1567,6 +1573,20 @@ export class ChannelDB implements IChannelDB {
};
}
async loadChannelAndLineupOrm(
channelId: string,
): Promise<ChannelAndLineup<ChannelOrm> | null> {
const channel = await this.getChannelOrm(channelId);
if (isNil(channel)) {
return null;
}
return {
channel,
lineup: await this.loadLineup(channelId),
};
}
async loadChannelWithProgamsAndLineup(
channelId: string,
): Promise<{ channel: ChannelOrmWithPrograms; lineup: Lineup } | null> {

View File

@@ -0,0 +1,25 @@
import type { TranscodeConfig } from '@tunarr/types';
import type {
TranscodeConfigNotFoundError,
WrappedError,
} from '../types/errors.ts';
import type { Result } from '../types/result.ts';
import type { Maybe } from '../types/util.ts';
import type { TranscodeConfigOrm } from './schema/TranscodeConfig.ts';
export interface ITranscodeConfigDB {
getAll(): Promise<TranscodeConfigOrm[]>;
getById(id: string): Promise<Maybe<TranscodeConfigOrm>>;
getDefaultConfig(): Promise<Maybe<TranscodeConfigOrm>>;
getChannelConfig(channelId: string): Promise<TranscodeConfigOrm>;
insertConfig(
config: Omit<TranscodeConfig, 'id'>,
): Promise<TranscodeConfigOrm>;
duplicateConfig(
id: string,
): Promise<
Result<TranscodeConfigOrm, TranscodeConfigNotFoundError | WrappedError>
>;
updateConfig(id: string, updatedConfig: TranscodeConfig): Promise<void>;
deleteConfig(id: string): Promise<void>;
}

View File

@@ -1,95 +1,83 @@
import { booleanToNumber } from '@/util/sqliteUtil.js';
import { Resolution, TranscodeConfig } from '@tunarr/types';
import { TranscodeConfig } from '@tunarr/types';
import { count, eq } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import { Kysely } from 'kysely';
import { omit } from 'lodash-es';
import { head, omit, sumBy } from 'lodash-es';
import { v4 } from 'uuid';
import { TranscodeConfigNotFoundError, WrappedError } from '../types/errors.ts';
import { KEYS } from '../types/inject.ts';
import { Result } from '../types/result.ts';
import { Maybe } from '../types/util.ts';
import { ITranscodeConfigDB } from './ITranscodeConfigDB.ts';
import { Channel } from './schema/Channel.ts';
import {
NewTranscodeConfig,
TranscodeConfig as TranscodeConfigDAO,
TranscodeConfigUpdate,
defaultTranscodeConfig,
NewTranscodeConfigOrm,
TranscodeConfig as TranscodeConfigTable,
type TranscodeConfigOrm,
} from './schema/TranscodeConfig.ts';
import { DB } from './schema/db.ts';
import { DrizzleDBAccess } from './schema/index.ts';
@injectable()
export class TranscodeConfigDB {
constructor(@inject(KEYS.Database) private db: Kysely<DB>) {}
export class TranscodeConfigDB implements ITranscodeConfigDB {
constructor(@inject(KEYS.DrizzleDB) private drizzle: DrizzleDBAccess) {}
getAll() {
return this.db.selectFrom('transcodeConfig').selectAll().execute();
async getAll(): Promise<TranscodeConfigOrm[]> {
return await this.drizzle.query.transcodeConfigs.findMany();
}
getById(id: string) {
return this.db
.selectFrom('transcodeConfig')
.where('uuid', '=', id)
.selectAll()
.executeTakeFirst();
async getById(id: string): Promise<Maybe<TranscodeConfigOrm>> {
return await this.drizzle.query.transcodeConfigs.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
});
}
getDefaultConfig() {
return this.db
.selectFrom('transcodeConfig')
.where('isDefault', '=', 1)
.limit(1)
.selectAll()
.executeTakeFirst();
async getDefaultConfig(): Promise<Maybe<TranscodeConfigOrm>> {
return await this.drizzle.query.transcodeConfigs.findFirst({
where: (fields, { eq }) => eq(fields.isDefault, true),
});
}
async getChannelConfig(channelId: string) {
const channelConfig = await this.db
.selectFrom('channel')
.where('channel.uuid', '=', channelId)
.innerJoin(
'transcodeConfig',
'channel.transcodeConfigId',
'transcodeConfig.uuid',
)
.selectAll('transcodeConfig')
.limit(1)
.executeTakeFirst();
async getChannelConfig(channelId: string): Promise<TranscodeConfigOrm> {
const channelConfig = await this.drizzle.query.channels.findFirst({
where: (fields, { eq }) => eq(fields.uuid, channelId),
with: {
transcodeConfig: true,
},
});
if (channelConfig) {
return channelConfig;
return channelConfig.transcodeConfig;
}
return this.db
.selectFrom('transcodeConfig')
.where('isDefault', '=', 1)
.selectAll()
.limit(1)
.executeTakeFirstOrThrow();
const defaultConfig = await this.getDefaultConfig();
if (!defaultConfig) {
throw new Error('Bad state - no default transcode config');
}
return defaultConfig;
}
insertConfig(config: Omit<TranscodeConfig, 'id'>) {
async insertConfig(
config: Omit<TranscodeConfig, 'id'>,
): Promise<TranscodeConfigOrm> {
const id = v4();
const newConfig: NewTranscodeConfig = {
const newConfig: NewTranscodeConfigOrm = {
...omit(config, 'id'),
uuid: id,
resolution: JSON.stringify(config.resolution),
normalizeFrameRate: booleanToNumber(config.normalizeFrameRate),
deinterlaceVideo: booleanToNumber(config.deinterlaceVideo),
disableChannelOverlay: booleanToNumber(config.disableChannelOverlay),
isDefault: booleanToNumber(config.isDefault),
disableHardwareDecoder: booleanToNumber(config.disableHardwareDecoder),
disableHardwareEncoding: booleanToNumber(config.disableHardwareEncoding),
disableHardwareFilters: booleanToNumber(config.disableHardwareFilters),
};
return this.db
.insertInto('transcodeConfig')
.values(newConfig)
.returningAll()
.executeTakeFirstOrThrow();
return head(
await this.drizzle
.insert(TranscodeConfigTable)
.values(newConfig)
.returning(),
)!;
}
async duplicateConfig(
id: string,
): Promise<
Result<TranscodeConfigDAO, TranscodeConfigNotFoundError | WrappedError>
Result<TranscodeConfigOrm, TranscodeConfigNotFoundError | WrappedError>
> {
const baseConfig = await this.getById(id);
if (!baseConfig) {
@@ -98,79 +86,59 @@ export class TranscodeConfigDB {
const newId = v4();
baseConfig.uuid = newId;
baseConfig.isDefault = booleanToNumber(false);
baseConfig.isDefault = false;
baseConfig.name = `${baseConfig.name} (copy)`;
return Result.attemptAsync(() => {
return this.db
.insertInto('transcodeConfig')
.values({
...baseConfig,
resolution: JSON.stringify(baseConfig.resolution),
})
.returningAll()
.executeTakeFirstOrThrow();
return Result.attemptAsync(async () => {
return head(
await this.drizzle
.insert(TranscodeConfigTable)
.values(baseConfig)
.returning()
.execute(),
)!;
});
}
updateConfig(id: string, updatedConfig: TranscodeConfig) {
const update: TranscodeConfigUpdate = {
...omit(updatedConfig, 'id'),
resolution: JSON.stringify(updatedConfig.resolution),
normalizeFrameRate: booleanToNumber(updatedConfig.normalizeFrameRate),
deinterlaceVideo: booleanToNumber(updatedConfig.deinterlaceVideo),
disableChannelOverlay: booleanToNumber(
updatedConfig.disableChannelOverlay,
),
isDefault: booleanToNumber(updatedConfig.isDefault),
disableHardwareDecoder: booleanToNumber(
updatedConfig.disableHardwareDecoder,
),
disableHardwareEncoding: booleanToNumber(
updatedConfig.disableHardwareEncoding,
),
disableHardwareFilters: booleanToNumber(
updatedConfig.disableHardwareFilters,
),
};
return this.db
.updateTable('transcodeConfig')
.where('uuid', '=', id)
.set(update)
.execute();
async updateConfig(
id: string,
updatedConfig: TranscodeConfig,
): Promise<void> {
await this.drizzle
.update(TranscodeConfigTable)
.set({
...omit(updatedConfig, 'id'),
})
.where(eq(TranscodeConfigTable.uuid, id));
}
deleteConfig(id: string) {
async deleteConfig(id: string) {
// A few cases to handle:
// 1. if we are deleting the default configuration, we have to pick a new one.
// 2. If we are deleting the last configuration, we have to create a default configuration
// 3. We have to update all related channels.
return this.db.transaction().execute(async (tx) => {
await this.drizzle.transaction(async (tx) => {
const numConfigs = await tx
.selectFrom('transcodeConfig')
.select((eb) => eb.fn.count<number>('uuid').as('count'))
.executeTakeFirst()
.then((res) => res?.count ?? 0);
.select({
count: count(),
})
.from(TranscodeConfigTable)
.then((results) => sumBy(results, (r) => r.count));
// If there are no configs (should be impossible) create a default, assign it to all channels
// and move on.
if (numConfigs === 0) {
const { uuid: newDefaultConfigId } =
await this.insertDefaultConfiguration(tx);
const newDefaultConfigId = await this.insertDefaultConfiguration(tx);
await tx
.updateTable('channel')
.set('transcodeConfigId', newDefaultConfigId)
.update(Channel)
.set({ transcodeConfigId: newDefaultConfigId })
.execute();
return;
}
const configToDelete = await tx
.selectFrom('transcodeConfig')
.where('uuid', '=', id)
.selectAll()
.limit(1)
.executeTakeFirst();
const configToDelete = await tx.query.transcodeConfigs.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
});
if (!configToDelete) {
return;
@@ -178,79 +146,136 @@ export class TranscodeConfigDB {
// If this is the last config, we'll need a new one and will have to assign it
if (numConfigs === 1) {
const { uuid: newDefaultConfigId } =
await this.insertDefaultConfiguration(tx);
const newDefaultConfigId = await this.insertDefaultConfiguration(tx);
await tx
.updateTable('channel')
.set('transcodeConfigId', newDefaultConfigId)
.update(Channel)
.set({ transcodeConfigId: newDefaultConfigId })
.execute();
await tx
.deleteFrom('transcodeConfig')
.where('uuid', '=', id)
.delete(TranscodeConfigTable)
.where(eq(TranscodeConfigTable.uuid, id))
.limit(1)
.execute();
return;
}
// We're deleting the default config. Pick a random one to make the new default. Not great!
let replacementId: string;
if (configToDelete.isDefault) {
const newDefaultConfig = await tx
.selectFrom('transcodeConfig')
.where('uuid', '!=', id)
.where('isDefault', '=', 0)
.select('uuid')
.limit(1)
.executeTakeFirstOrThrow();
const newDefaultConfig = (
await tx
.select({ uuid: TranscodeConfigTable.uuid })
.from(TranscodeConfigTable)
.where(eq(TranscodeConfigTable.isDefault, false))
.limit(1)
)[0]!;
await tx
.updateTable('transcodeConfig')
.set('isDefault', 1)
.where('uuid', '=', newDefaultConfig.uuid)
.limit(1)
.execute();
await tx
.updateTable('channel')
.set('transcodeConfigId', newDefaultConfig.uuid)
.execute();
.update(TranscodeConfigTable)
.set({ isDefault: true })
.where(eq(TranscodeConfigTable.uuid, newDefaultConfig.uuid));
replacementId = newDefaultConfig.uuid;
} else {
const defaultId = (
await tx
.select({ uuid: TranscodeConfigTable.uuid })
.from(TranscodeConfigTable)
.where(eq(TranscodeConfigTable.isDefault, true))
.limit(1)
)[0]!;
replacementId = defaultId.uuid;
}
await tx
.deleteFrom('transcodeConfig')
.where('uuid', '=', id)
.update(Channel)
.set({ transcodeConfigId: replacementId })
.where(eq(Channel.transcodeConfigId, configToDelete.uuid))
.execute();
await tx
.delete(TranscodeConfigTable)
.where(eq(TranscodeConfigTable.uuid, id))
.limit(1)
.execute();
});
// const numConfigs = await tx
// .selectFrom('transcodeConfig')
// .select((eb) => eb.fn.count<number>('uuid').as('count'))
// .executeTakeFirst()
// .then((res) => res?.count ?? 0);
// // If there are no configs (should be impossible) create a default, assign it to all channels
// // and move on.
// if (numConfigs === 0) {
// const { uuid: newDefaultConfigId } =
// await this.insertDefaultConfiguration(tx);
// await tx
// .updateTable('channel')
// .set('transcodeConfigId', newDefaultConfigId)
// .execute();
// return;
// }
// const configToDelete = await tx
// .selectFrom('transcodeConfig')
// .where('uuid', '=', id)
// .selectAll()
// .limit(1)
// .executeTakeFirst();
// if (!configToDelete) {
// return;
// }
// // If this is the last config, we'll need a new one and will have to assign it
// if (numConfigs === 1) {
// const { uuid: newDefaultConfigId } =
// await this.insertDefaultConfiguration(tx);
// await tx
// .updateTable('channel')
// .set('transcodeConfigId', newDefaultConfigId)
// .execute();
// await tx
// .deleteFrom('transcodeConfig')
// .where('uuid', '=', id)
// .limit(1)
// .execute();
// return;
// }
// // We're deleting the default config. Pick a random one to make the new default. Not great!
// if (configToDelete.isDefault) {
// const newDefaultConfig = await tx
// .selectFrom('transcodeConfig')
// .where('uuid', '!=', id)
// .where('isDefault', '=', 0)
// .select('uuid')
// .limit(1)
// .executeTakeFirstOrThrow();
// await tx
// .updateTable('transcodeConfig')
// .set('isDefault', 1)
// .where('uuid', '=', newDefaultConfig.uuid)
// .limit(1)
// .execute();
// await tx
// .updateTable('channel')
// .set('transcodeConfigId', newDefaultConfig.uuid)
// .execute();
// }
// await tx
// .deleteFrom('transcodeConfig')
// .where('uuid', '=', id)
// .limit(1)
// .execute();
// });
}
private async insertDefaultConfiguration(db: Kysely<DB> = this.db) {
return db
.insertInto('transcodeConfig')
.values(TranscodeConfigDB.createDefaultConfiguration())
.returning('uuid as uuid')
.executeTakeFirstOrThrow();
}
private static createDefaultConfiguration(): NewTranscodeConfig {
const id = v4();
return {
uuid: id,
name: 'Default Config',
threadCount: 0,
resolution: JSON.stringify({
widthPx: 1920,
heightPx: 1080,
} satisfies Resolution),
audioBitRate: 192,
audioBufferSize: 192 * 3,
audioChannels: 2,
audioSampleRate: 48,
audioFormat: 'aac',
hardwareAccelerationMode: 'none',
normalizeFrameRate: booleanToNumber(false),
deinterlaceVideo: booleanToNumber(true),
videoBitRate: 3500,
videoBufferSize: 3500 * 2,
videoFormat: 'h264',
isDefault: booleanToNumber(true),
};
private async insertDefaultConfiguration(db: DrizzleDBAccess = this.drizzle) {
return head(
await db
.insert(TranscodeConfigTable)
.values(defaultTranscodeConfig(true))
.returning({ uuid: TranscodeConfigTable.uuid }),
)!.uuid;
}
}

View File

@@ -1,9 +1,35 @@
import type { TranscodeConfig } from '@tunarr/types';
import { numberToBoolean } from '../../util/sqliteUtil.ts';
import type { TranscodeConfig as TrannscodeConfigDao } from '../schema/TranscodeConfig.ts';
import type {
TranscodeConfig as TranscodeConfigDAO,
TranscodeConfigOrm,
} from '../schema/TranscodeConfig.ts';
export function dbTranscodeConfigToApiSchema(
config: TrannscodeConfigDao,
export function transcodeConfigOrmToDto(
config: TranscodeConfigOrm,
): TranscodeConfig {
return {
...config,
id: config.uuid,
// disableChannelOverlay: numberToBoolean(config.disableChannelOverlay),
// normalizeFrameRate: numberToBoolean(config.normalizeFrameRate),
// deinterlaceVideo: numberToBoolean(config.deinterlaceVideo),
// isDefault: numberToBoolean(config.isDefault),
// disableHardwareDecoder: numberToBoolean(config.disableHardwareDecoder),
// disableHardwareEncoding: numberToBoolean(config.disableHardwareEncoding),
// disableHardwareFilters: numberToBoolean(config.disableHardwareFilters),
disableChannelOverlay: config.disableChannelOverlay ?? false,
normalizeFrameRate: config.normalizeFrameRate ?? false,
deinterlaceVideo: config.deinterlaceVideo ?? false,
isDefault: config.isDefault ?? false,
disableHardwareDecoder: config.disableHardwareDecoder ?? false,
disableHardwareEncoding: config.disableHardwareEncoding ?? false,
disableHardwareFilters: config.disableHardwareFilters ?? false,
} satisfies TranscodeConfig;
}
export function legacyTranscodeConfigToDto(
config: TranscodeConfigDAO,
): TranscodeConfig {
return {
...config,

View File

@@ -9,6 +9,7 @@ import type { ProgramDao } from '@/db/schema/Program.js';
import type { ProgramExternalId } from '@/db/schema/ProgramExternalId.js';
import type {
ChannelOrmWithRelations,
ChannelOrmWithTranscodeConfig,
ChannelWithRelations,
MusicArtistOrm,
ProgramWithRelationsOrm,
@@ -42,7 +43,9 @@ export type ChannelAndRawLineup = { channel: ChannelOrm; lineup: Json };
export interface IChannelDB {
channelExists(channelId: string): Promise<boolean>;
getChannelOrm(id: string | number): Promise<Maybe<ChannelOrm>>;
getChannelOrm(
id: string | number,
): Promise<Maybe<ChannelOrmWithTranscodeConfig>>;
getChannel(id: string | number): Promise<Maybe<ChannelWithRelations>>;
getChannel(
@@ -147,6 +150,10 @@ export interface IChannelDB {
channelId: string,
): Promise<ChannelAndLineup<Channel> | null>;
loadChannelAndLineupOrm(
channelId: string,
): Promise<ChannelAndLineup<ChannelOrm> | null>;
addPendingPrograms(
channelId: string,
pendingPrograms: PendingProgram[],

View File

@@ -21,6 +21,7 @@ import { ChannelFillerShow } from './ChannelFillerShow.ts';
import { ChannelPrograms } from './ChannelPrograms.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { ProgramPlayHistory } from './ProgramPlayHistory.ts';
import { TranscodeConfig } from './TranscodeConfig.ts';
export const Channel = sqliteTable(
'channel',
@@ -73,9 +74,13 @@ export type NewChannel = Insertable<ChannelTable>;
export type ChannelUpdate = Updateable<ChannelTable>;
export type ChannelOrm = InferSelectModel<typeof Channel>;
export const ChannelRelations = relations(Channel, ({ many }) => ({
export const ChannelRelations = relations(Channel, ({ many, one }) => ({
channelPrograms: many(ChannelPrograms),
channelCustomShows: many(ChannelCustomShow),
channelFillerShow: many(ChannelFillerShow),
playHistory: many(ProgramPlayHistory),
transcodeConfig: one(TranscodeConfig, {
fields: [Channel.transcodeConfigId],
references: [TranscodeConfig.uuid],
}),
}));

View File

@@ -1,4 +1,5 @@
import type { Resolution, TupleToUnion } from '@tunarr/types';
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { inArray } from 'drizzle-orm';
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
@@ -200,8 +201,38 @@ export type TranscodeConfig = Selectable<TranscodeConfigTable>;
export type NewTranscodeConfig = Insertable<TranscodeConfigTable>;
export type TranscodeConfigUpdate = Updateable<TranscodeConfigTable>;
export type TranscodeConfigOrm = InferSelectModel<typeof TranscodeConfig>;
export type NewTranscodeConfigOrm = InferInsertModel<typeof TranscodeConfig>;
export const defaultTranscodeConfig = (
isDefault?: boolean,
): NewTranscodeConfigOrm => {
return {
threadCount: 0,
audioBitRate: 192,
audioBufferSize: 192 * 2,
audioChannels: 2,
audioFormat: 'aac',
audioSampleRate: 48,
hardwareAccelerationMode: 'none',
name: isDefault ? 'Default' : `h264 @ 1920x1080`,
resolution: {
widthPx: 1920,
heightPx: 1080,
} satisfies Resolution,
uuid: v4(),
videoBitRate: 2000,
videoBufferSize: 4000,
videoFormat: 'h264',
disableChannelOverlay: false,
normalizeFrameRate: false,
videoBitDepth: 8,
isDefault: !!isDefault,
};
};
export const defaultTranscodeConfigLegacy = (
isDefault?: boolean,
): NewTranscodeConfig => {
return {
threadCount: 0,

View File

@@ -1,4 +1,7 @@
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type {
TranscodeConfig,
TranscodeConfigOrm,
} from '@/db/schema/TranscodeConfig.js';
import type { MarkNonNullable, Nullable } from '@/types/util.js';
import type { Insertable } from 'kysely';
import type { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
@@ -182,7 +185,7 @@ export type ChannelOrmWithRelations = ChannelOrm & {
programs?: ProgramWithRelationsOrm[];
fillerContent?: ProgramWithRelationsOrm[];
fillerShows?: ChannelFillerShow[];
transcodeConfig?: TranscodeConfig;
transcodeConfig?: TranscodeConfigOrm;
subtitlePreferences?: ChannelSubtitlePreferences[];
};
@@ -204,6 +207,11 @@ export type ChannelOrmWithPrograms = MarkRequired<
'programs'
>;
export type ChannelOrmWithTranscodeConfig = MarkRequired<
ChannelOrmWithRelations,
'transcodeConfig'
>;
export type ChannelFillerShowWithRelations = ChannelFillerShow & {
fillerShow: MarkNonNullable<DeepNullable<FillerShow>, 'uuid'>;
fillerContent?: ProgramWithRelations[];

View File

@@ -87,6 +87,7 @@ import {
TagRelations,
TagRelationSchema,
} from './Tag.ts';
import { TranscodeConfig } from './TranscodeConfig.ts';
// export { Program } from './Program.ts';
@@ -157,6 +158,7 @@ export const schema = {
tagRelations: TagJoinRelationSchema,
tagJoin: TagRelations,
tagJoinRelations: TagRelationSchema,
transcodeConfigs: TranscodeConfig,
};
export type DrizzleDBAccess = BetterSQLite3Database<typeof schema>;

View File

@@ -646,19 +646,21 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
);
return result.mapPure((data) => {
const playlists = (data.MediaContainer.Metadata ?? []).map(
(playlist) =>
({
externalId: playlist.ratingKey,
libraryId: '',
mediaSourceId: this.options.mediaSource.uuid,
sourceType: this.options.mediaSource.type,
title: playlist.title,
type: 'playlist',
uuid: v4(),
childCount: playlist.leafCount,
}) satisfies Playlist,
);
const playlists = (data.MediaContainer.Metadata ?? [])
.filter((playlist) => playlist.playlistType !== 'photo')
.map(
(playlist) =>
({
externalId: playlist.ratingKey,
libraryId: '',
mediaSourceId: this.options.mediaSource.uuid,
sourceType: this.options.mediaSource.type,
title: playlist.title,
type: 'playlist',
uuid: v4(),
childCount: playlist.leafCount,
}) satisfies Playlist,
);
return {
result: playlists,

View File

@@ -1,19 +1,19 @@
import type { ISettingsDB } from '@/db/interfaces/ISettingsDB.js';
import type { Channel } from '@/db/schema/Channel.js';
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.js';
import type { IFFMPEG } from '@/ffmpeg/ffmpegBase.js';
import { KEYS } from '@/types/inject.js';
import type { ChannelStreamMode } from '@tunarr/types';
import { ContainerModule } from 'inversify';
import type { IChannelDB } from '../db/interfaces/IChannelDB.ts';
import type { ChannelOrm } from '../db/schema/Channel.ts';
import { bindFactoryFunc } from '../util/inject.ts';
import type { PipelineBuilderFactory } from './builder/pipeline/PipelineBuilderFactory.ts';
import { FfmpegInfo } from './ffmpegInfo.ts';
export type FFmpegFactory = (
transcodeConfig: TranscodeConfig,
channel: Channel,
transcodeConfig: TranscodeConfigOrm,
channel: ChannelOrm,
streamMode: ChannelStreamMode,
) => IFFMPEG;

View File

@@ -1,4 +1,4 @@
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import {
HardwareAccelerationMode,
TranscodeAudioOutputFormat,
@@ -6,7 +6,6 @@ import {
import type { ChannelStreamMode } from '@/db/schema/base.js';
import type { StreamDetails, VideoStreamDetails } from '@/stream/types.js';
import { gcd } from '@/util/index.js';
import { numberToBoolean } from '@/util/sqliteUtil.js';
import type { Resolution } from '@tunarr/types';
import { ChannelStreamModes } from '@tunarr/types';
import type { OutputFormat, VideoFormat } from './builder/constants.ts';
@@ -16,7 +15,7 @@ import { FrameSize } from './builder/types.ts';
export class FfmpegPlaybackParamsCalculator {
constructor(
private transcodeConfig: TranscodeConfig,
private transcodeConfig: TranscodeConfigOrm,
private streamMode: ChannelStreamMode,
) {}
@@ -95,7 +94,7 @@ export class FfmpegPlaybackParamsCalculator {
params.pixelFormat = new PixelFormatYuv420P();
params.deinterlace =
numberToBoolean(this.transcodeConfig.deinterlaceVideo) &&
!!this.transcodeConfig.deinterlaceVideo &&
videoStream.scanType === 'interlaced';
}
@@ -165,7 +164,7 @@ export type FfmpegPlaybackParams = {
};
function needsToScale(
transcodeConfig: TranscodeConfig,
transcodeConfig: TranscodeConfigOrm,
videoStreamDetails: VideoStreamDetails,
) {
return (
@@ -213,7 +212,7 @@ function isAnamorphic(videoStreamDetails: VideoStreamDetails) {
}
function calculateScaledSize(
config: TranscodeConfig,
config: TranscodeConfigOrm,
videoStream: VideoStreamDetails,
) {
const { widthPx: targetW, heightPx: targetH } = config.resolution;

View File

@@ -2,8 +2,8 @@ import type {
ISettingsDB,
ReadableFfmpegSettings,
} from '@/db/interfaces/ISettingsDB.js';
import type { Channel } from '@/db/schema/Channel.js';
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { ChannelOrm } from '@/db/schema/Channel.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import { InfiniteLoopInputOption } from '@/ffmpeg/builder/options/input/InfiniteLoopInputOption.js';
import type { AudioStreamDetails } from '@/stream/types.js';
import { FileStreamSource, HttpStreamSource } from '@/stream/types.js';
@@ -18,7 +18,6 @@ import { isUndefined } from 'lodash-es';
import type { DeepReadonly, NonEmptyArray } from 'ts-essentials';
import { match, P } from 'ts-pattern';
import type { IChannelDB } from '../db/interfaces/IChannelDB.ts';
import { numberToBoolean } from '../util/sqliteUtil.ts';
import { FfmpegPlaybackParamsCalculator } from './FfmpegPlaybackParamsCalculator.ts';
import { FfmpegProcess } from './FfmpegProcess.ts';
import { FfmpegTranscodeSession } from './FfmpegTrancodeSession.ts';
@@ -72,8 +71,8 @@ export class FfmpegStreamFactory extends IFFMPEG {
constructor(
private ffmpegSettings: ReadableFfmpegSettings,
private transcodeConfig: TranscodeConfig,
private channel: Channel,
private transcodeConfig: TranscodeConfigOrm,
private channel: ChannelOrm,
private ffmpegInfo: FfmpegInfo,
private settingsDB: ISettingsDB,
private pipelineBuilderFactory: PipelineBuilderFactory,
@@ -504,11 +503,11 @@ export class FfmpegStreamFactory extends IFFMPEG {
vaapiDevice: this.getVaapiDevice(),
vaapiDriver: this.getVaapiDriver(),
disableHardwareDecoding:
numberToBoolean(this.transcodeConfig.disableHardwareDecoder) ?? false,
this.transcodeConfig.disableHardwareDecoder ?? false,
disableHardwareEncoding:
numberToBoolean(this.transcodeConfig.disableHardwareEncoding) ?? false,
this.transcodeConfig.disableHardwareEncoding ?? false,
disableHardwareFilters:
numberToBoolean(this.transcodeConfig.disableHardwareFilters) ?? false,
this.transcodeConfig.disableHardwareFilters ?? false,
};
const pipeline = builder.build(

View File

@@ -1,5 +1,5 @@
import type { ReadableFfmpegSettings } from '@/db/interfaces/ISettingsDB.js';
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import { HardwareAccelerationMode } from '@/db/schema/TranscodeConfig.js';
import type {
BaseFfmpegHardwareCapabilities,
@@ -16,7 +16,7 @@ export class HardwareCapabilitiesFactory
{
constructor(
private ffmpegSettings: ReadableFfmpegSettings,
private transcodeConfig: TranscodeConfig,
private transcodeConfig: TranscodeConfigOrm,
) {}
async getCapabilities(): Promise<BaseFfmpegHardwareCapabilities> {

View File

@@ -1,7 +1,7 @@
import { seq } from '@tunarr/shared/util';
import { drop, isEmpty, map, reject, split, trim } from 'lodash-es';
import type { ReadableFfmpegSettings } from '../../../db/interfaces/ISettingsDB.ts';
import type { TranscodeConfig } from '../../../db/schema/TranscodeConfig.ts';
import type { TranscodeConfigOrm } from '../../../db/schema/TranscodeConfig.ts';
import { ChildProcessHelper } from '../../../util/ChildProcessHelper.ts';
import { LoggerFactory } from '../../../util/logging/LoggerFactory.ts';
import { FFmpegOptionsExtractionPattern } from '../../ffmpegInfo.ts';
@@ -21,7 +21,7 @@ export class QsvHardwareCapabilitiesFactory
});
constructor(
private transcodeConfig: TranscodeConfig,
private transcodeConfig: TranscodeConfigOrm,
private ffmpegSettings: ReadableFfmpegSettings,
) {}

View File

@@ -1,4 +1,4 @@
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import type { FfmpegHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.js';
import { DefaultHardwareCapabilities } from '@/ffmpeg/builder/capabilities/DefaultHardwareCapabilities.js';
import { NoHardwareCapabilities } from '@/ffmpeg/builder/capabilities/NoHardwareCapabilities.js';
@@ -26,7 +26,7 @@ export class VaapiHardwareCapabilitiesFactory
return `vainfo_${driver}_${device}`;
}
constructor(private transcodeConfig: TranscodeConfig) {}
constructor(private transcodeConfig: TranscodeConfigOrm) {}
async getCapabilities() {
// windows check bail!

View File

@@ -2,7 +2,7 @@ import type {
ISettingsDB,
ReadableFfmpegSettings,
} from '@/db/interfaces/ISettingsDB.js';
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import { HardwareAccelerationMode } from '@/db/schema/TranscodeConfig.js';
import { HardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/HardwareCapabilitiesFactory.js';
import type { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.js';
@@ -24,7 +24,7 @@ import { NvidiaPipelineBuilder } from './nvidia/NvidiaPipelineBuilder.ts';
import { SoftwarePipelineBuilder } from './software/SoftwarePipelineBuilder.ts';
export type PipelineBuilderFactory = (
transcodeConfig: TranscodeConfig,
transcodeConfig: TranscodeConfigOrm,
) => PipelineBuilderFactory$Builder;
export const FfmpegPipelineBuilderModule = new ContainerModule((bind) => {
@@ -56,7 +56,7 @@ class PipelineBuilderFactory$Builder {
constructor(
private ffmpegSettings: ReadableFfmpegSettings,
private ffmpegInfo: FfmpegInfo,
private transcodeConfig: TranscodeConfig,
private transcodeConfig: TranscodeConfigOrm,
) {}
setVideoInputSource(

View File

@@ -3,14 +3,14 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { spawn } from 'node:child_process';
import events from 'node:events';
import type { ReadableFfmpegSettings } from '../db/interfaces/ISettingsDB.ts';
import type { TranscodeConfig } from '../db/schema/TranscodeConfig.ts';
import type { TranscodeConfigOrm } from '../db/schema/TranscodeConfig.ts';
export class FfmpegText extends events.EventEmitter {
private args: string[];
private ffmpeg: ChildProcessWithoutNullStreams;
constructor(
transcodeConfig: TranscodeConfig,
transcodeConfig: TranscodeConfigOrm,
opts: ReadableFfmpegSettings,
title: string,
subtitle: string,

View File

@@ -1,5 +1,5 @@
import type { NewTranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import { defaultTranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import { defaultTranscodeConfigLegacy } from '@/db/schema/TranscodeConfig.js';
import type { DB } from '@/db/schema/db.js';
import { booleanToNumber } from '@/util/sqliteUtil.js';
import type { Resolution } from '@tunarr/types';
@@ -91,7 +91,7 @@ export default {
)
.execute();
const defaultConfig = defaultTranscodeConfig(true);
const defaultConfig = defaultTranscodeConfigLegacy(true);
const transcodeConfigId = (
await db
.insertInto('transcodeConfig')

View File

@@ -1,4 +1,4 @@
import type { Channel } from '@/db/schema/Channel.js';
import type { ChannelOrm } from '@/db/schema/Channel.js';
import { ChannelCache } from '@/stream/ChannelCache.js';
import type { Maybe } from '@/types/util.js';
import { random } from '@/util/random.js';
@@ -31,7 +31,7 @@ export class FillerPicker implements IFillerPicker {
}
pickFiller(
channel: Channel,
channel: ChannelOrm,
fillers: ChannelFillerShowWithContent[],
maxDuration: number,
): Promise<FillerPickResult> {

View File

@@ -1,4 +1,4 @@
import type { Channel } from '../../db/schema/Channel.ts';
import type { ChannelOrm } from '../../db/schema/Channel.ts';
import type {
ChannelFillerShowWithContent,
ProgramWithRelations,
@@ -19,7 +19,7 @@ export const EmptyFillerPickResult: FillerPickResult = {
export interface IFillerPicker {
pickFiller(
channel: Channel,
channel: ChannelOrm,
fillers: ChannelFillerShowWithContent[],
maxDuration: number,
now?: number,

View File

@@ -2,7 +2,7 @@ import dayjs from 'dayjs';
import { inject, injectable } from 'inversify';
import { groupBy, isEmpty, maxBy, sumBy } from 'lodash-es';
import { ProgramPlayHistoryDB } from '../../db/ProgramPlayHistoryDB.ts';
import { Channel } from '../../db/schema/Channel.ts';
import { ChannelOrm } from '../../db/schema/Channel.ts';
import { ChannelFillerShowWithContent } from '../../db/schema/derivedTypes.ts';
import {
FiveMinutesMillis,
@@ -34,7 +34,7 @@ export class FillerPickerV2 implements IFillerPicker {
) {}
async pickFiller(
channel: Channel,
channel: ChannelOrm,
fillers: ChannelFillerShowWithContent[],
maxDuration: number,
now = +dayjs(),

View File

@@ -1,4 +1,4 @@
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js';
import type { ChannelConcatStreamMode } from '@tunarr/types/schemas';
import { isEmpty } from 'lodash-es';
@@ -12,7 +12,7 @@ export type ConcatSessionOptions = SessionOptions & {
};
export type ConcatSessionFactory = (
channel: ChannelWithTranscodeConfig,
channel: ChannelOrmWithTranscodeConfig,
options: ConcatSessionOptions,
) => ConcatSession;
@@ -20,7 +20,7 @@ export class ConcatSession extends DirectStreamSession<ConcatSessionOptions> {
#transcodeSession: FfmpegTranscodeSession;
constructor(
channel: ChannelWithTranscodeConfig,
channel: ChannelOrmWithTranscodeConfig,
options: ConcatSessionOptions,
private concatStreamFactory: ConcatStreamFactory,
) {

View File

@@ -1,4 +1,4 @@
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { FFmpegFactory } from '@/ffmpeg/FFmpegModule.js';
import type { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js';
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
@@ -7,12 +7,12 @@ import { makeFfmpegPlaylistUrl, makeLocalUrl } from '@/util/serverUtil.js';
import type { ChannelConcatStreamMode } from '@tunarr/types/schemas';
export type ConcatStreamFactory = (
channel: ChannelWithTranscodeConfig,
channel: ChannelOrmWithTranscodeConfig,
streamMode: ChannelConcatStreamMode,
) => ConcatStream;
export class ConcatStream {
constructor(
private channel: ChannelWithTranscodeConfig,
private channel: ChannelOrmWithTranscodeConfig,
private streamMode: ChannelConcatStreamMode,
private ffmpegFactory: FFmpegFactory,
) {}

View File

@@ -1,4 +1,4 @@
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js';
import { once, round } from 'lodash-es';
import type { Readable } from 'node:stream';
@@ -15,7 +15,7 @@ export abstract class DirectStreamSession<
> extends Session<TOpts> {
#stream: Readable;
protected constructor(channel: ChannelWithTranscodeConfig, opts: TOpts) {
protected constructor(channel: ChannelOrmWithTranscodeConfig, opts: TOpts) {
super(channel, opts);
}

View File

@@ -1,6 +1,6 @@
import type { StreamLineupItem } from '@/db/derived_types/StreamLineup.js';
import type { Channel } from '@/db/schema/Channel.js';
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { ChannelOrm } from '@/db/schema/Channel.js';
import type { TranscodeConfigOrm } from '@/db/schema/TranscodeConfig.js';
import type { ChannelStreamMode } from '@tunarr/types';
import dayjs from 'dayjs';
import type { GetCurrentLineupItemRequest } from './StreamProgramCalculator.ts';
@@ -20,21 +20,21 @@ export class PlayerContext {
*/
constructor(
public lineupItem: StreamLineupItem,
public targetChannel: Channel,
public sourceChannel: Channel,
public targetChannel: ChannelOrm,
public sourceChannel: ChannelOrm,
public audioOnly: boolean,
public realtime: boolean,
public transcodeConfig: TranscodeConfig,
public transcodeConfig: TranscodeConfigOrm,
public streamMode: ChannelStreamMode,
) {}
static error(
duration: number,
error: string | boolean | Error,
targetChannel: Channel,
sourceChannel: Channel,
targetChannel: ChannelOrm,
sourceChannel: ChannelOrm,
realtime: boolean,
transcodeConfig: TranscodeConfig,
transcodeConfig: TranscodeConfigOrm,
streamMode: ChannelStreamMode,
): PlayerContext {
return new PlayerContext(

View File

@@ -1,4 +1,4 @@
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { TypedEventEmitter } from '@/types/eventEmitter.js';
import { Result } from '@/types/result.js';
import type { Maybe } from '@/types/util.js';
@@ -58,7 +58,7 @@ export abstract class Session<
protected lock = new Mutex();
protected logger: Logger;
protected sessionOptions: TOpts;
protected channel: ChannelWithTranscodeConfig;
protected channel: ChannelOrmWithTranscodeConfig;
protected connectionTracker: ConnectionTracker<StreamConnectionDetails>;
#state: SessionState = 'init';
@@ -66,7 +66,7 @@ export abstract class Session<
error: Maybe<Error>;
constructor(channel: ChannelWithTranscodeConfig, opts: TOpts) {
constructor(channel: ChannelOrmWithTranscodeConfig, opts: TOpts) {
super();
this.#uniqueId = v4();
this.logger = LoggerFactory.child({

View File

@@ -33,7 +33,7 @@ import {
import { HlsSlowerSession } from './hls/HlsSlowerSession.js';
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import { OnDemandChannelService } from '@/services/OnDemandChannelService.js';
import { KEYS } from '@/types/inject.js';
import { ifDefined } from '@/util/index.js';
@@ -258,7 +258,7 @@ export class SessionManager {
token: string,
connection: StreamConnectionDetails,
sessionType: SessionType,
sessionFactory: (channel: ChannelWithTranscodeConfig) => TSession,
sessionFactory: (channel: ChannelOrmWithTranscodeConfig) => TSession,
): Promise<Result<TSession, TypedError>> {
const lock = await this.#sessionLocker.getOrCreateLock(channelId);
try {
@@ -269,12 +269,9 @@ export class SessionManager {
) as Maybe<TSession>;
if (isNil(session)) {
const channel = await this.channelDB
.getChannelBuilder(channelId)
.withTranscodeConfig()
.executeTakeFirst();
const channel = await this.channelDB.getChannelOrm(channelId);
if (isNil(channel)) {
if (!channel?.transcodeConfig) {
throw new ChannelNotFoundError(channelId);
}

View File

@@ -16,7 +16,7 @@ import { MediaSourceId } from '../db/schema/base.ts';
import { IStreamLineupCache } from '../interfaces/IStreamLineupCache.ts';
import { IFillerPicker } from '../services/interfaces/IFillerPicker.ts';
import {
createChannel,
createChannelOrm,
createFakeProgram,
} from '../testing/fakes/entityCreators.ts';
import { LoggerFactory } from '../util/logging/LoggerFactory.ts';
@@ -72,14 +72,14 @@ describe('StreamProgramCalculator', () => {
),
);
const channel = createChannel({
const channel = createChannelOrm({
uuid: channelId,
number: 1,
startTime: +startTime.subtract(1, 'hour'),
duration: sumBy(lineup, ({ durationMs }) => durationMs),
});
when(channelDB.getChannel(1)).thenReturn(Promise.resolve(channel));
when(channelDB.getChannelOrm(1)).thenReturn(Promise.resolve(channel));
when(channelDB.loadLineup(channelId)).thenReturn(
Promise.resolve({
@@ -183,14 +183,14 @@ describe('StreamProgramCalculator', () => {
),
);
const channel = createChannel({
const channel = createChannelOrm({
uuid: channelId,
number: 1,
startTime: +startTime.subtract(1, 'hour'),
duration: sumBy(lineup, ({ durationMs }) => durationMs),
});
when(channelDB.getChannel(1)).thenReturn(Promise.resolve(channel));
when(channelDB.getChannelOrm(1)).thenReturn(Promise.resolve(channel));
when(channelDB.loadLineup(channelId)).thenReturn(
Promise.resolve({
@@ -296,14 +296,14 @@ describe('StreamProgramCalculator', () => {
),
);
const channel = createChannel({
const channel = createChannelOrm({
uuid: channelId,
number: 1,
startTime: +startTime.subtract(1, 'hour'),
duration: sumBy(lineup, ({ durationMs }) => durationMs),
});
when(channelDB.getChannel(1)).thenReturn(Promise.resolve(channel));
when(channelDB.getChannelOrm(1)).thenReturn(Promise.resolve(channel));
when(channelDB.loadLineup(channelId)).thenReturn(
Promise.resolve({

View File

@@ -1,4 +1,4 @@
import { Channel } from '@/db/schema/Channel.js';
import { ChannelOrm } from '@/db/schema/Channel.js';
import type { ProgramWithRelations as RawProgramEntity } from '@/db/schema/derivedTypes.js';
import { KEYS } from '@/types/inject.js';
import { Result } from '@/types/result.js';
@@ -67,8 +67,8 @@ export type CurrentLineupItemResult = {
lineupItem: StreamLineupItem;
// Either the source channel or the target channel
// if the current program is a redirect
channelContext: Channel;
sourceChannel: Channel;
channelContext: ChannelOrm;
sourceChannel: ChannelOrm;
};
@injectable()
@@ -90,7 +90,7 @@ export class StreamProgramCalculator {
req: GetCurrentLineupItemRequest,
): Promise<Result<CurrentLineupItemResult>> {
const startTime = req.startTime;
const channel = await this.channelDB.getChannel(req.channelId);
const channel = await this.channelDB.getChannelOrm(req.channelId);
if (isNil(channel)) {
return Result.failure(
@@ -111,7 +111,7 @@ export class StreamProgramCalculator {
}
let lineupItem: Maybe<StreamLineupItem>;
let channelContext: Channel = channel;
let channelContext: ChannelOrm = channel;
const redirectChannels: string[] = [];
const upperBounds: number[] = [];
@@ -148,7 +148,7 @@ export class StreamProgramCalculator {
const nextChannelId = currentProgram.program.channel;
const newChannelAndLineup =
await this.channelDB.loadChannelAndLineup(nextChannelId);
await this.channelDB.loadChannelAndLineupOrm(nextChannelId);
if (isNil(newChannelAndLineup)) {
const msg = "Invalid redirect to a channel that doesn't exist";
@@ -418,7 +418,7 @@ export class StreamProgramCalculator {
async createLineupItem(
{ program, timeElapsed }: ProgramAndTimeElapsed,
streamDuration: number,
channel: Channel,
channel: ChannelOrm,
): Promise<StreamLineupItem> {
if (program.type === 'redirect') {
throw new Error(

View File

@@ -76,10 +76,7 @@ export class VideoStream {
// const serverCtx = getServerContext();
const outStream = new PassThrough();
const channel = await this.channelDB
.getChannelBuilder(channelIdOrNumber)
.withTranscodeConfig()
.executeTakeFirst();
const channel = await this.channelDB.getChannelOrm(channelIdOrNumber);
if (isNil(channel)) {
return {

View File

@@ -1,4 +1,4 @@
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { SessionOptions } from '@/stream/Session.js';
import { Session } from '@/stream/Session.js';
import { Result } from '@/types/result.js';
@@ -41,7 +41,10 @@ export abstract class BaseHlsSession<
return minBy([...this._minByIp.entries()], ([_, seg]) => seg)?.[1] ?? 0;
}
constructor(channel: ChannelWithTranscodeConfig, options: HlsSessionOptsT) {
constructor(
channel: ChannelOrmWithTranscodeConfig,
options: HlsSessionOptsT,
) {
super(channel, options);
this._workingDirectory = path.join(

View File

@@ -1,5 +1,5 @@
import type { ISettingsDB } from '@/db/interfaces/ISettingsDB.js';
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js';
import { GetLastPtsDurationTask } from '@/ffmpeg/GetLastPtsDuration.js';
import type { HlsOptions, OutputFormat } from '@/ffmpeg/builder/constants.js';
@@ -30,12 +30,12 @@ import type { HlsPlaylistFilterOptions } from './HlsPlaylistMutator.js';
import { HlsPlaylistMutator } from './HlsPlaylistMutator.js';
export type HlsSessionProvider = (
channel: ChannelWithTranscodeConfig,
channel: ChannelOrmWithTranscodeConfig,
options: HlsSessionOptions,
) => HlsSession;
export type HlsSlowerSessionProvider = (
channel: ChannelWithTranscodeConfig,
channel: ChannelOrmWithTranscodeConfig,
options: BaseHlsSessionOptions,
) => HlsSlowerSession;
@@ -55,7 +55,7 @@ export class HlsSession extends BaseHlsSession<HlsSessionOptions> {
#isFirstTranscode = true;
constructor(
channel: ChannelWithTranscodeConfig,
channel: ChannelOrmWithTranscodeConfig,
options: HlsSessionOptions,
private programCalculator: StreamProgramCalculator,
private settingsDB: ISettingsDB,

View File

@@ -1,4 +1,4 @@
import type { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { ChannelOrmWithTranscodeConfig } from '@/db/schema/derivedTypes.js';
import type { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js';
import type { ProgramStream } from '@/stream/ProgramStream.js';
import type { StreamProgramCalculator } from '@/stream/StreamProgramCalculator.js';
@@ -33,7 +33,7 @@ export class HlsSlowerSession extends BaseHlsSession {
#concatSession: FfmpegTranscodeSession;
constructor(
channel: ChannelWithTranscodeConfig,
channel: ChannelOrmWithTranscodeConfig,
options: BaseHlsSessionOptions,
programCalculator: StreamProgramCalculator,
private programStreamFactory: ProgramStreamFactory,

View File

@@ -1,4 +1,4 @@
import { defaultTranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import { defaultTranscodeConfigLegacy } from '@/db/schema/TranscodeConfig.js';
import Fixer from '@/tasks/fixers/fixer.js';
import { KEYS } from '@/types/inject.js';
import { type Logger } from '@/util/logging/LoggerFactory.js';
@@ -71,7 +71,7 @@ export class EnsureTranscodeConfigIds extends Fixer {
return (
await this.db
.insertInto('transcodeConfig')
.values(defaultTranscodeConfig(true))
.values(defaultTranscodeConfigLegacy(true))
.returning('uuid')
.executeTakeFirstOrThrow()
).uuid;

View File

@@ -1,7 +1,7 @@
import { faker } from '@faker-js/faker';
import { tag } from '@tunarr/types';
import { MultiExternalIdType } from '@tunarr/types/schemas';
import type { Channel } from '../../db/schema/Channel.ts';
import type { Channel, ChannelOrm } from '../../db/schema/Channel.ts';
import type { ProgramDao } from '../../db/schema/Program.ts';
import { ProgramTypes } from '../../db/schema/Program.ts';
import type { MinimalProgramExternalId } from '../../db/schema/ProgramExternalId.ts';
@@ -24,6 +24,18 @@ export function createChannel(overrides?: Partial<Channel>): Channel {
} satisfies Channel;
}
export function createChannelOrm(overrides?: Partial<ChannelOrm>): ChannelOrm {
return {
uuid: faker.string.uuid(),
duration: faker.number.int({ min: 0 }),
startTime: faker.date.past().getTime(),
createdAt: null,
disableFillerOverlay: faker.datatype.boolean(),
name: faker.music.artist(),
...(overrides ?? {}),
} satisfies ChannelOrm;
}
export function createFakeMultiExternalId(): MinimalProgramExternalId {
const typ = faker.helpers.arrayElement(MultiExternalIdType);
return {

View File

@@ -232,7 +232,7 @@ export const PlexPlaylistSchema = z.object({
titleSort: z.string().optional(),
summary: z.string().optional(),
smart: z.boolean().optional(),
playlistType: z.union([z.literal('video'), z.literal('audio')]).optional(), // Add new known types here
playlistType: z.enum(['video', 'audio', 'photo']).optional(), // Add new known types here
composite: z.string().optional(), // Thumb path
icon: z.string().optional(),
viewCount: z.number().optional(),