From b73b2ed91223ebae4254074236a47cd570fbad81 Mon Sep 17 00:00:00 2001 From: Olly Namey <9575458+OllysCoding@users.noreply.github.com> Date: Sat, 18 Jan 2025 22:01:29 +0000 Subject: [PATCH] Expand config options --- README.md | 3 - apps/backend/src/config/app.ts | 164 +++++++++++++++--- apps/backend/src/config/load.ts | 2 - apps/backend/src/fonts/index.ts | 2 +- apps/backend/src/index.ts | 21 ++- .../helpers/getBackgroundContentForChannel.ts | 157 +++++++++++++++++ .../src/jobs/helpers/getChannelConfig.ts | 17 ++ .../backend/src/jobs/helpers/getFillLength.ts | 48 +++++ apps/backend/src/jobs/helpers/pickRandom.ts | 3 + apps/backend/src/jobs/index.ts | 38 ++++ apps/backend/src/jobs/main.ts | 141 ++++++--------- apps/backend/src/logger/index.ts | 12 +- apps/backend/src/templates/index.ts | 2 +- .../createProgrammeInfoFromProgrammes.ts | 62 +++++++ apps/backend/src/video-generator/index.ts | 93 +++------- apps/backend/src/xmltv/index.ts | 8 +- package-lock.json | 67 ++++++- 17 files changed, 629 insertions(+), 211 deletions(-) delete mode 100644 apps/backend/src/config/load.ts create mode 100644 apps/backend/src/jobs/helpers/getBackgroundContentForChannel.ts create mode 100644 apps/backend/src/jobs/helpers/getChannelConfig.ts create mode 100644 apps/backend/src/jobs/helpers/getFillLength.ts create mode 100644 apps/backend/src/jobs/helpers/pickRandom.ts create mode 100644 apps/backend/src/jobs/index.ts create mode 100644 apps/backend/src/video-generator/helpers/createProgrammeInfoFromProgrammes.ts diff --git a/README.md b/README.md index 3eb60d7..539a3cc 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,6 @@ Bumpgen offers a number of default templates to use for your channels, as well a ## What's still needed for MVP? -- Give templates all upcoming shows not just the next one & build 'left-panel-next-5' template -- Options for resolution (per channel) -- Option for length including \* as 'fill' - Plugins: some kind of versioning + publish types + example repo + language/locale in template - Frontend for configuring - Allow animations? diff --git a/apps/backend/src/config/app.ts b/apps/backend/src/config/app.ts index 7aa1ba1..9b65e43 100644 --- a/apps/backend/src/config/app.ts +++ b/apps/backend/src/config/app.ts @@ -1,19 +1,50 @@ import { Ajv, type JSONSchemaType } from "ajv"; import addFormats from "ajv-formats"; import { logError, LogLevel } from "../logger/index.js"; -import { readFileSync } from "node:fs"; import { exit } from "node:process"; +import { readFile } from "node:fs/promises"; +import { failure, success, type Result } from "../result/index.js"; const DEFAULT_CONFIG_PATH = "../../configs/bumpgen.config.json"; const ajv = new Ajv(); addFormats(ajv); -type ChannelId = string; +type BackgroundContentPath = string; export interface ChannelConfig { + /** + * Array of channels this config applies to + */ + channelIds: "*" | string[]; + /** + * e.g "centre-title-and-time" + */ template: string; + /** + * "*" = All background content + * ["path/relative/to/bg/folder",] + */ backgroundContent: "*" | string[]; + /** + * Time in secodns + */ + length: "*" | number; + resolution: { + width: number; + height: number; + }; + /** + * Time in seconds + */ + padding?: number; +} + +export interface BackgroundContentConfig { + /** + * [startSeconds, endSeconds] + */ + windows: [number, number][]; } export interface AppConfig { @@ -27,7 +58,8 @@ export interface AppConfig { xmlTvUrl: string; outputFolder: string; backgroundContentFolder: string; - channels: Record; + backgroundContent?: Record; + channels: ChannelConfig[]; /** * TODO */ @@ -47,14 +79,46 @@ const schema: JSONSchemaType = { xmlTvUrl: { type: "string", format: "uri" }, outputFolder: { type: "string" }, backgroundContentFolder: { type: "string" }, - channels: { + backgroundContent: { type: "object", + nullable: true, additionalProperties: { type: "object", properties: { + windows: { + type: "array", + items: { + type: "array", + items: [{ type: "number" }, { type: "number" }], + maxItems: 2, + minItems: 2, + }, + }, + }, + required: [], + }, + required: [], + }, + channels: { + type: "array", + items: { + type: "object", + properties: { + channelIds: { + oneOf: [ + { + type: "array", + items: { type: "string" }, + minItems: 1, + }, + { + type: "string", + enum: ["*"], + }, + ], + }, template: { type: "string", - // enum: ["centre-title-and-time"], }, backgroundContent: { oneOf: [ @@ -62,10 +126,30 @@ const schema: JSONSchemaType = { { type: "array", items: { type: "string" } }, ], }, + length: { + oneOf: [{ type: "string", enum: ["*"] }, { type: "number" }], + }, + resolution: { + type: "object", + properties: { + width: { type: "number" }, + height: { type: "number" }, + }, + required: ["width", "height"], + }, + padding: { + type: "number", + nullable: true, + }, }, - required: ["template", "backgroundContent"], + required: [ + "channelIds", + "template", + "backgroundContent", + "length", + "resolution", + ], }, - required: [], }, }, required: [ @@ -85,24 +169,56 @@ const validate = ajv.compile(schema); export const configFilePath = process.env.CONFIG_FILE_PATH || DEFAULT_CONFIG_PATH; -const getConfig = (): AppConfig => { - try { - const value = readFileSync(configFilePath, "utf-8"); - const parsed = JSON.parse(value); - - if (validate(parsed)) { - return parsed; - } else { - logError("Failed to initiliaze: error parsing config", validate.errors); +class App { + private _config: AppConfig | undefined = undefined; + private _initialized = false; + public get config(): AppConfig { + if (this._config === undefined) { + logError("Tried to access config before it was initialized"); exit(1); } - } catch (err) { - logError( - "Failed to initiliaze: config file cannot be opened at " + configFilePath, - err, - ); - exit(1); + + return this._config; } -}; + private set config(value: AppConfig) { + this._config = { ...value }; + } + + public get isInitialized() { + return this._initialized; + } + + private initialize = (config: AppConfig) => { + this.config = config; + this._initialized = true; + }; + + loadConfig = async (reload = false): Promise> => { + if (this._config !== undefined && !reload) return success(undefined); + + try { + const value = await readFile(configFilePath, "utf-8"); + const parsed = JSON.parse(value); + + if (validate(parsed)) { + this.initialize(parsed); + return success(undefined); + } else { + logError("Failed to initiliaze: error parsing config", validate.errors); + exit(1); + } + } catch (err) { + logError( + "Failed to initiliaze: config file cannot be opened at " + + configFilePath, + err, + ); + return failure( + "Failed to initiliaze: config file cannot be opened at " + + configFilePath, + ); + } + }; +} -export const appConfig: AppConfig = getConfig(); +export const appConfig = new App(); diff --git a/apps/backend/src/config/load.ts b/apps/backend/src/config/load.ts deleted file mode 100644 index d242686..0000000 --- a/apps/backend/src/config/load.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Ensure all the configs are loaded by importing them -import "./app.js"; diff --git a/apps/backend/src/fonts/index.ts b/apps/backend/src/fonts/index.ts index eba8ace..2d9dbd9 100644 --- a/apps/backend/src/fonts/index.ts +++ b/apps/backend/src/fonts/index.ts @@ -87,7 +87,7 @@ export abstract class Fonts { const defaultFiles = await glob("./fonts/**/font-map.json", { absolute: true, }); - const pluginFiles = appConfig.experimentalPluginsSupport + const pluginFiles = appConfig.config.experimentalPluginsSupport ? await glob(path.join(configFilePath, "/plugins/**/font-map.json"), { absolute: true, }) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b07dba7..2af27fb 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,15 +1,20 @@ -// Load configs import "dotenv/config"; -import "./config/load.js"; -import { scheduleJob } from "node-schedule"; - -import main from "./jobs/main.js"; import { appConfig } from "./config/app.js"; import { Templates } from "./templates/index.js"; import { Fonts } from "./fonts/index.js"; +import { jobScheduler } from "./jobs/index.js"; + +const initialize = async () => { + await appConfig.loadConfig(); + + if (appConfig.isInitialized) { + await Templates.registerTemplates(); + await Fonts.registerFonts(); -await Templates.registerTemplates(); -await Fonts.registerFonts(); + // Startup any jobs + jobScheduler.startup(); + } +}; -scheduleJob(`*/${appConfig.interval || 5} * * * *`, main); +initialize(); diff --git a/apps/backend/src/jobs/helpers/getBackgroundContentForChannel.ts b/apps/backend/src/jobs/helpers/getBackgroundContentForChannel.ts new file mode 100644 index 0000000..31ab382 --- /dev/null +++ b/apps/backend/src/jobs/helpers/getBackgroundContentForChannel.ts @@ -0,0 +1,157 @@ +import ffmpeg from "fluent-ffmpeg"; +import { glob } from "glob"; +import { + appConfig, + type BackgroundContentConfig, + type ChannelConfig, +} from "../../config/app.js"; +import { + failure, + isSuccess, + success, + type Result, +} from "../../result/index.js"; +import { logDebug, logError } from "../../logger/index.js"; +import { pickRandom } from "./pickRandom.js"; +import { isNotUndefined } from "bumpgen-shared/utils"; +import { resolve } from "node:path"; + +const getRelativePath = (absolutePath: string): string => { + return absolutePath.slice( + resolve(appConfig.config.backgroundContentFolder).length + 1, + ); +}; + +const getBackgroundContentConfig = ( + filepath: string, +): BackgroundContentConfig | undefined => { + return appConfig.config.backgroundContent?.[filepath]; +}; + +const getLengthOfVideoFile = (filepath: string): Promise> => { + return new Promise((resolve) => { + ffmpeg.ffprobe(filepath, (err, data) => { + if (err) { + logError(`Failed to get file info ${filepath}`, err); + resolve(failure("Unknown ffprobe error", err)); + } else if (data.format.duration) { + resolve(success(data.format.duration)); + } else { + logError(`Ffprobe did not return duration for file ${filepath}`); + resolve(failure("ffprobe did not return duration")); + } + }); + }); +}; + +const getFittingWindows = ( + windows: [number, number][], + length: number, + fileEnd: number, +) => { + return windows.filter( + ([start, end]) => end <= fileEnd && end - start >= length, + ); +}; + +const getFilesWhichFitLength = async ( + files: [string, string][], + length: number, +): Promise<{ file: [string, string]; windows: [number, number][] }[]> => { + const filteredFiles: { + file: [string, string]; + windows: [number, number][]; + }[] = []; + for (const [filepath, name] of files) { + const fileLength = await getLengthOfVideoFile(filepath); + if (isSuccess(fileLength)) { + const config = getBackgroundContentConfig(name); + if (config) { + const windows = getFittingWindows( + config.windows, + length, + fileLength.result, + ); + if (windows.length > 0) { + filteredFiles.push({ + file: [filepath, name], + windows, + }); + } + } else if (fileLength.result >= length) { + filteredFiles.push({ + file: [filepath, name], + windows: [[0, fileLength.result]], + }); + } + } + } + return filteredFiles; +}; + +export const getBackgroundContentForChannel = async ( + channelConfig: ChannelConfig, + length: number, +): Promise< + Result<{ + filePath: string; + startSeconds: number; + endSeconds: number; + }> +> => { + const allAvailableFiles: [string, string][] = ( + await glob(`${appConfig.config.backgroundContentFolder}/*`, { + absolute: true, + }) + ) + .map( + (filename) => [filename, getRelativePath(filename)] as [string, string], + ) + .filter(([, relative]) => isNotUndefined(relative)); + + if (allAvailableFiles.length === 0) { + return failure("No background content available"); + } + + let filteredFiles: [string, string][] = allAvailableFiles; + if (Array.isArray(channelConfig.backgroundContent)) { + filteredFiles = allAvailableFiles.filter(([, name]) => + channelConfig.backgroundContent.includes(name), + ); + if (filteredFiles.length !== channelConfig.backgroundContent.length) { + const missing = allAvailableFiles.filter( + ([, name]) => !channelConfig.backgroundContent.includes(name), + ); + logDebug( + "Some files configured for channel are missing from background contents folder: ", + missing, + ); + } + if (filteredFiles.length === 0) { + return failure("No background content available once filter is applied"); + } + } + + const options = await getFilesWhichFitLength(filteredFiles, length); + + if (options.length === 0) { + logError(`No files available which fit required length: ${length}`); + return failure("No files which fit length"); + } else if (options.length !== filteredFiles.length) { + const missing = filteredFiles.filter( + ([, name]) => options.findIndex(({ file }) => file[1] === name) === -1, + ); + logDebug( + "Some files were not long enough to be used for background content: ", + missing, + ); + } + + const pickedFile = pickRandom(options); + const [startSeconds, endSeconds] = pickRandom(pickedFile.windows); + return success({ + filePath: pickedFile.file[0], + startSeconds, + endSeconds, + }); +}; diff --git a/apps/backend/src/jobs/helpers/getChannelConfig.ts b/apps/backend/src/jobs/helpers/getChannelConfig.ts new file mode 100644 index 0000000..f2b49e5 --- /dev/null +++ b/apps/backend/src/jobs/helpers/getChannelConfig.ts @@ -0,0 +1,17 @@ +import { appConfig, type ChannelConfig } from "../../config/app.js"; + +export const getChannelConfig = ( + channelId: string, +): ChannelConfig | undefined => { + let defaultConfig: ChannelConfig | undefined = undefined; + return ( + appConfig.config.channels.find((config) => { + if (config.channelIds === "*") { + defaultConfig = config; + return false; + } else { + return config.channelIds.includes(channelId); + } + }) ?? defaultConfig + ); +}; diff --git a/apps/backend/src/jobs/helpers/getFillLength.ts b/apps/backend/src/jobs/helpers/getFillLength.ts new file mode 100644 index 0000000..0f81e40 --- /dev/null +++ b/apps/backend/src/jobs/helpers/getFillLength.ts @@ -0,0 +1,48 @@ +import type { XmltvProgramme } from "@iptv/xmltv"; +import type { NextProgrammes } from "../../xmltv/index.js"; +import { logInfo } from "../../logger/index.js"; + +const DEFAULT_LENGTH = 60; + +export const getFillLength = (programmes: NextProgrammes): number => { + if (!programmes[1]) { + logInfo( + `"*" length option used but no next programme found, defaulting to ${DEFAULT_LENGTH} seconds.`, + ); + return DEFAULT_LENGTH; + } + + const getEndsAtTime = (programme: XmltvProgramme): Date | undefined => { + if (programme.stop) return programme.stop; + else if (programme.length) { + const time = programme.start.getTime(); + switch (programme.length.units) { + case "hours": + return new Date(time + programme.length._value * 60 * 60 * 1000); + case "minutes": + return new Date(time + programme.length._value * 60 * 1000); + case "seconds": + return new Date(time + programme.length._value * 1000); + } + } else return undefined; + }; + + const currentEnd = getEndsAtTime(programmes[0]); + if (!currentEnd) { + logInfo( + `"*" option used but no information on end time of current programme available, defaulting to ${DEFAULT_LENGTH} seconds.`, + ); + return DEFAULT_LENGTH; + } + const nextStart = programmes[1].start; + const length = Math.max((nextStart.getTime() - currentEnd.getTime()) / 1000); + + if (length === 0) { + logInfo( + `"*" lrngth option used but 0 seconds between start & end times, default to ${DEFAULT_LENGTH} seconds.`, + ); + return DEFAULT_LENGTH; + } + + return length; +}; diff --git a/apps/backend/src/jobs/helpers/pickRandom.ts b/apps/backend/src/jobs/helpers/pickRandom.ts new file mode 100644 index 0000000..bd54d12 --- /dev/null +++ b/apps/backend/src/jobs/helpers/pickRandom.ts @@ -0,0 +1,3 @@ +export const pickRandom = (arr: T[]): T => { + return arr[Math.floor(Math.random() * arr.length)] as T; +}; diff --git a/apps/backend/src/jobs/index.ts b/apps/backend/src/jobs/index.ts new file mode 100644 index 0000000..fab4801 --- /dev/null +++ b/apps/backend/src/jobs/index.ts @@ -0,0 +1,38 @@ +import { scheduleJob, type Job, type JobCallback } from "node-schedule"; + +import MainJob from "./main.js"; + +type JobName = "main"; + +const jobMap: Record< + JobName, + { + job: JobCallback; + getSchedule: () => string; + } +> = { + main: MainJob, +}; + +class JobScheduler { + private scheduledJobs: Record = { + main: undefined, + }; + + startJob = (name: JobName) => { + if (this.scheduledJobs[name]) { + this.scheduledJobs[name].reschedule(jobMap[name].getSchedule()); + } else { + this.scheduledJobs[name] = scheduleJob( + jobMap[name].getSchedule(), + jobMap[name].job, + ); + } + }; + + startup = () => { + this.startJob("main"); + }; +} + +export const jobScheduler = new JobScheduler(); diff --git a/apps/backend/src/jobs/main.ts b/apps/backend/src/jobs/main.ts index 9dda487..b180c47 100644 --- a/apps/backend/src/jobs/main.ts +++ b/apps/backend/src/jobs/main.ts @@ -3,64 +3,16 @@ import { getNextProgrammesForChannel, getValueForConfiguredLang, } from "../xmltv/index.js"; -import { - failure, - isFailure, - isSuccess, - success, - unwrap, - type Result, -} from "../result/index.js"; -import { - createProgrammeInfoFromProgrammes, - makeVideo, -} from "../video-generator/index.js"; +import { failure, isFailure, isSuccess, unwrap } from "../result/index.js"; +import { makeVideo } from "../video-generator/index.js"; import { appConfig, type ChannelConfig } from "../config/app.js"; import type { XmltvChannel, XmltvProgramme } from "@iptv/xmltv"; import { logDebug, logError, logInfo } from "../logger/index.js"; -import { glob } from "glob"; import { Templates } from "../templates/index.js"; - -const getChannelConfig = (channelId: string): ChannelConfig | undefined => { - return appConfig.channels[channelId] || appConfig.channels["*"] || undefined; -}; - -const pickRandom = (arr: T[]): T => { - return arr[Math.floor(Math.random() * arr.length)] as T; -}; - -const getBackgroundContentForChannel = async ( - channelConfig: ChannelConfig, -): Promise> => { - const allAvailableFiles = await glob( - `${appConfig.backgroundContentFolder}/*`, - { absolute: true }, - ); - if (allAvailableFiles.length === 0) { - return failure("No background content available"); - } - - if (Array.isArray(channelConfig.backgroundContent)) { - const filteredContent = allAvailableFiles.filter((name) => - channelConfig.backgroundContent.includes(name), - ); - if (filteredContent.length !== channelConfig.backgroundContent.length) { - const missing = allAvailableFiles.filter( - (name) => !channelConfig.backgroundContent.includes(name), - ); - logDebug( - "Some files configured for channel are missing from background contents folder: ", - missing, - ); - } - if (filteredContent.length === 0) { - return failure("No background content available once filter is applied"); - } - return success(pickRandom(filteredContent)); - } else { - return success(pickRandom(allAvailableFiles)); - } -}; +import { getFillLength } from "./helpers/getFillLength.js"; +import { getBackgroundContentForChannel } from "./helpers/getBackgroundContentForChannel.js"; +import { getChannelConfig } from "./helpers/getChannelConfig.js"; +import { createProgrammeInfoFromProgrammes } from "../video-generator/helpers/createProgrammeInfoFromProgrammes.js"; const channelTask = async ( channel: XmltvChannel, @@ -72,6 +24,11 @@ const channelTask = async ( return failure("Failed to get next programme", nextProgrammes.error); } + const length = + channelConfig.length === "*" + ? getFillLength(nextProgrammes.result) + : channelConfig.length; + const programmeInfo = createProgrammeInfoFromProgrammes( nextProgrammes.result, ); @@ -80,10 +37,12 @@ const channelTask = async ( return failure("Failed to get overlay config", programmeInfo.error); } - const backgroundContentPath = - await getBackgroundContentForChannel(channelConfig); - if (isFailure(backgroundContentPath)) { - return backgroundContentPath; + const backgroundContent = await getBackgroundContentForChannel( + channelConfig, + length, + ); + if (isFailure(backgroundContent)) { + return backgroundContent; } const template = Templates.getTemplateByName(channelConfig.template); @@ -91,48 +50,56 @@ const channelTask = async ( return template; } + const resolution = channelConfig.resolution ?? { + width: 1920, + height: 1080, + }; + return makeVideo({ + ...resolution, + length, template: template.result, programmes: programmeInfo.result, - outputDir: appConfig.outputFolder, + outputDir: appConfig.config.outputFolder, outputFileName: `channel-${channel.id}.mp4`, - length: 20, channelInfo: { id: channel.id, name: unwrap(getValueForConfiguredLang(channel.displayName)), }, - background: { - filePath: backgroundContentPath.result, - startSeconds: 10, - endSeconds: 2711, - }, + background: backgroundContent.result, }); }; -export default async () => { - logInfo(`Starting main job...`); - const xmlTv = await fetchAndParseXmlTv(appConfig.xmlTvUrl); - if (isSuccess(xmlTv)) { - for (const channel of xmlTv.result.channels) { - const channelConfig = getChannelConfig(channel.id); - if (!channelConfig) { - logDebug(`Skipping channel ${channel.id}, no config available`); - continue; - } +export default { + getSchedule: () => `*/${appConfig.config.interval || 5} * * * *`, + job: async () => { + logInfo(`Starting main job...`); + const xmlTv = await fetchAndParseXmlTv(appConfig.config.xmlTvUrl); + if (isSuccess(xmlTv)) { + for (const channel of xmlTv.result.channels) { + const channelConfig = getChannelConfig(channel.id); + if (!channelConfig) { + logDebug(`Skipping channel ${channel.id}, no config available`); + continue; + } - logInfo(`Started task for channel ${channel.id}`); - const result = await channelTask( - channel, - xmlTv.result.programmes, - channelConfig, - ); - if (isFailure(result)) { - logError(`Failed channel task for channel ${channel.id}`, result.error); - } else { - logInfo( - `Completed task for channel ${channel.id} (Video ${result.result.replace("-", " ")})`, + logInfo(`Started task for channel ${channel.id}`); + const result = await channelTask( + channel, + xmlTv.result.programmes, + channelConfig, ); + if (isFailure(result)) { + logError( + `Failed channel task for channel ${channel.id}`, + result.error, + ); + } else { + logInfo( + `Completed task for channel ${channel.id} (Video ${result.result.replace("-", " ")})`, + ); + } } } - } + }, }; diff --git a/apps/backend/src/logger/index.ts b/apps/backend/src/logger/index.ts index eb27e9e..7c0cbfe 100644 --- a/apps/backend/src/logger/index.ts +++ b/apps/backend/src/logger/index.ts @@ -1,4 +1,4 @@ -import assert from "node:assert"; +import { appConfig } from "../config/app.js"; export enum LogLevel { DEBUG = "DEBUG", @@ -6,22 +6,22 @@ export enum LogLevel { ERROR = "ERROR", } -const logLevel = (process.env.LOG_LEVEL as LogLevel) || LogLevel.DEBUG; - const logLevelMap = { [LogLevel.DEBUG]: [LogLevel.DEBUG, LogLevel.INFO, LogLevel.ERROR], [LogLevel.INFO]: [LogLevel.INFO, LogLevel.ERROR], [LogLevel.ERROR]: [LogLevel.ERROR], }; -assert(Array.isArray(logLevelMap[logLevel]), "Invalid log level configured"); - export const log = ( level: LogLevel, message: string, ...args: unknown[] ): void => { - if (logLevelMap[logLevel].includes(level)) { + if ( + logLevelMap[ + appConfig.isInitialized ? appConfig.config.logLevel : LogLevel.DEBUG + ].includes(level) + ) { console.log(`[${new Date().toISOString()} - ${level}] ` + message, ...args); } }; diff --git a/apps/backend/src/templates/index.ts b/apps/backend/src/templates/index.ts index a619ce0..83f6b31 100644 --- a/apps/backend/src/templates/index.ts +++ b/apps/backend/src/templates/index.ts @@ -19,7 +19,7 @@ export abstract class Templates { const defaultFiles = await glob("../../dist/templates/src/*.js", { absolute: true, }); - const pluginFiles = appConfig.experimentalPluginsSupport + const pluginFiles = appConfig.config.experimentalPluginsSupport ? await glob(path.join(configFilePath, "/plugins/**/plugin.js"), { absolute: true, }) diff --git a/apps/backend/src/video-generator/helpers/createProgrammeInfoFromProgrammes.ts b/apps/backend/src/video-generator/helpers/createProgrammeInfoFromProgrammes.ts new file mode 100644 index 0000000..0baf1a8 --- /dev/null +++ b/apps/backend/src/video-generator/helpers/createProgrammeInfoFromProgrammes.ts @@ -0,0 +1,62 @@ +import type { XmltvProgramme } from "@iptv/xmltv"; + +import type { ProgrammeInfo } from "bumpgen-shared/types"; +import { isNotUndefined } from "bumpgen-shared/utils"; + +import { + getBestIcon, + getOnScreenEpisodeNumber, + getValueForConfiguredLang, + type NextProgrammes, +} from "../../xmltv/index.js"; +import { + failure, + isFailure, + success, + unwrap, + type Result, +} from "../../result/index.js"; + +export const createProgrammeInfoFromProgrammes = ( + programmes: NextProgrammes, +): Result<[ProgrammeInfo, ...ProgrammeInfo[]]> => { + const createProgrammeInfo = ( + programme: XmltvProgramme, + ): Result => { + const title = getValueForConfiguredLang(programme.title); + if (isFailure(title)) { + return failure("Title required to create overlay"); + } + + const subtitle = getValueForConfiguredLang(programme.subTitle); + const episode = getOnScreenEpisodeNumber(programme.episodeNum); + const description = getValueForConfiguredLang(programme.desc); + const iconUrl = getBestIcon(programme.icon); + + return success({ + title: title.result, + subtitle: unwrap(subtitle), + episode: unwrap(episode), + description: unwrap(description), + start: programme.start, + end: programme.stop, + iconUrl: unwrap(iconUrl), + }); + }; + + const firstItemResult = createProgrammeInfo(programmes[0]); + + if (isFailure(firstItemResult)) { + // Pass on the failure + return firstItemResult; + } + + return success([ + firstItemResult.result, + ...programmes + .slice(1) + .map(createProgrammeInfo) + .map(unwrap) + .filter(isNotUndefined), + ]); +}; diff --git a/apps/backend/src/video-generator/index.ts b/apps/backend/src/video-generator/index.ts index c44dd6b..c1df117 100644 --- a/apps/backend/src/video-generator/index.ts +++ b/apps/backend/src/video-generator/index.ts @@ -1,19 +1,6 @@ -import type { XmltvProgramme } from "@iptv/xmltv"; import type { FabricTemplate, ProgrammeInfo } from "bumpgen-shared/types"; -import { isNotUndefined } from "bumpgen-shared/utils"; - -import { - failure, - isFailure, - success, - unwrap, - type Result, -} from "../result/index.js"; -import { - getBestIcon, - getOnScreenEpisodeNumber, - getValueForConfiguredLang, -} from "../xmltv/index.js"; + +import { failure, isFailure, success, type Result } from "../result/index.js"; import { renderer, encoder } from "../canvas2video/index.js"; @@ -41,6 +28,8 @@ export interface VideoOptions { background?: VideoBackground; outputDir: string; outputFileName: string; + width: number; + height: number; length: number; template: FabricTemplate; } @@ -119,21 +108,28 @@ export const makeVideo = async ( return success("not-generated"); } - try { - const width = 1920; - const height = 1080; + const { + width, + height, + length, + programmes, + outputDir, + outputFileName, + background, + } = options; + try { const stream = await renderer({ width, height, fps: 1, makeScene: async (fabric, canvas, anim, compose) => { - await options.template(options.programmes, { + await options.template(programmes, { getFontProperties: (...args) => Fonts.getFontProperties(...args), convertX: (val: number) => val * width, convertY: (val: number) => val * height, })(fabric, canvas, anim); - anim.duration(options.length); + anim.duration(length); compose(); }, }); @@ -142,28 +138,25 @@ export const makeVideo = async ( width: 1920, height: 1080, frameStream: stream, - output: path.join(options.outputDir, options.outputFileName), + output: path.join(outputDir, outputFileName), fps: { input: 1, output: 30, }, }; - if (options.background) { - const { start, end } = getBackgroundVideoStartAndEnd( - options.background, - options.length, - ); + if (background) { + const { start, end } = getBackgroundVideoStartAndEnd(background, length); await encoder({ ...baseEncoderConfig, backgroundVideo: { - videoPath: options.background.filePath, + videoPath: background.filePath, cut: { startSeconds: start, endSeconds: end, }, inSeconds: 0, - outSeconds: options.length, + outSeconds: length, }, }); } else { @@ -175,47 +168,3 @@ export const makeVideo = async ( return failure("Failed to create video", err); } }; - -export const createProgrammeInfoFromProgrammes = ( - programmes: [XmltvProgramme, ...XmltvProgramme[]], -): Result<[ProgrammeInfo, ...ProgrammeInfo[]]> => { - const createProgrammeInfo = ( - programme: XmltvProgramme, - ): Result => { - const title = getValueForConfiguredLang(programme.title); - if (isFailure(title)) { - return failure("Title required to create overlay"); - } - - const subtitle = getValueForConfiguredLang(programme.subTitle); - const episode = getOnScreenEpisodeNumber(programme.episodeNum); - const description = getValueForConfiguredLang(programme.desc); - const iconUrl = getBestIcon(programme.icon); - - return success({ - title: title.result, - subtitle: unwrap(subtitle), - episode: unwrap(episode), - description: unwrap(description), - start: programme.start, - end: programme.stop, - iconUrl: unwrap(iconUrl), - }); - }; - - const firstItemResult = createProgrammeInfo(programmes[0]); - - if (isFailure(firstItemResult)) { - // Pass on the failure - return firstItemResult; - } - - return success([ - firstItemResult.result, - ...programmes - .slice(1) - .map(createProgrammeInfo) - .map(unwrap) - .filter(isNotUndefined), - ]); -}; diff --git a/apps/backend/src/xmltv/index.ts b/apps/backend/src/xmltv/index.ts index 56b6e04..d133cc7 100644 --- a/apps/backend/src/xmltv/index.ts +++ b/apps/backend/src/xmltv/index.ts @@ -42,10 +42,12 @@ export const fetchAndParseXmlTv = async ( } }; +export type NextProgrammes = [XmltvProgramme, ...XmltvProgramme[]]; + export const getNextProgrammesForChannel = ( channel: XmltvChannel, programmes: XmltvProgramme[], -): Result<[XmltvProgramme, ...XmltvProgramme[]]> => { +): Result => { const forChannel = programmes.filter((p) => p.channel === channel.id); const sorted = [...forChannel].sort((a, b) => { return a.start.getTime() - b.start.getTime(); @@ -101,7 +103,7 @@ export const getValueForConfiguredLang = ( return failure("Field is empty"); } - const value = arr.find((v) => v.lang === appConfig.language); + const value = arr.find((v) => v.lang === appConfig.config.language); if (value) { return success(value._value); } else { @@ -109,7 +111,7 @@ export const getValueForConfiguredLang = ( const fallback = arr.find((v) => v.lang === undefined); if (fallback) return success(fallback._value); } - return failure("Failed to find field for lang", +appConfig.language); + return failure("Failed to find field for lang", +appConfig.config.language); } }; diff --git a/package-lock.json b/package-lock.json index cd7812a..1fd36da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "fabric": "^6.5.4", "fastify": "^5.1.0", "ffmpeg-static": "^4.4.1", + "ffprobe": "^1.1.2", "ffprobe-static": "^3.1.0", "fluent-ffmpeg": "^2.1.3", "glob": "^11.0.0", @@ -2185,7 +2186,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2237,6 +2237,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2264,7 +2275,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -2659,6 +2669,15 @@ "node": ">=10" } }, + "node_modules/deferential": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/deferential/-/deferential-1.0.0.tgz", + "integrity": "sha512-QyFNvptDP8bypD6WK6ZOXFSBHN6CFLZmQ59QyvRGDvN9+DoX01mxw28QrJwSVPrrwnMWqHgTRiXybH6Y0cBbWw==", + "license": "BSD-3-Clause", + "dependencies": { + "native-promise-only": "^0.8.1" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3240,6 +3259,17 @@ "node": ">=10" } }, + "node_modules/ffprobe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/ffprobe/-/ffprobe-1.1.2.tgz", + "integrity": "sha512-a+oTbhyeM7Z8PRy+mpzmVUAnATZT7z4BO94HSKeqHupdmjiKZ1djzcZkyoyXA21zCOCG7oVRrsBMmvvtmzoz4g==", + "license": "BSD-3-Clause", + "dependencies": { + "bl": "^4.0.3", + "deferential": "^1.0.0", + "JSONStream": "^1.3.5" + } + }, "node_modules/ffprobe-static": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz", @@ -3867,7 +3897,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -4154,6 +4183,31 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4438,6 +4492,12 @@ "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", "license": "MIT" }, + "node_modules/native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5784,7 +5844,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, "license": "MIT" }, "node_modules/to-regex-range": {