Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Bumper content are clips which can be used inbetween the main content of your TV

A file is output for each channel on your service, and which is replaced each time a new show starts (with an updated bumper based on what will be playing next). This content can then be imported in your chosen IPTV service.

---

<h2 align=center>🛠️ Project under active development 🛠️</h2>
<p align=center>There is no official release yet (though dev releases are available on <code>ollyscoding/bumpgen-test:&lt;commit-sha&gt;</code>), and breaking changes will be actively made until v0.1 is released</p>

---

## Getting Started

You can run bumpgen through docker compose, create a docker compose file from the [the template](./docs/compose.yml).
Expand All @@ -13,3 +20,36 @@ You'll need to update the `/path/to/your/output` and `/path/to/your/background-c
If you haven't already, create the config directory, then create a file called `bumpgen.config.json` within it, and copy across the contents of [bumpgen.config.example.json](./configs/bumpgen.config.example.json). Then add the URL for your XML TV file, and change any other settings as desired.

You should now be good to go, run `docker compose up -d` to get started!

### Templates

Bumpgen offers a number of default templates to use for your channels, as well as exerimental plugin support if you want to develop your own templates.

<table>
<tr>
<th>Name</th>
<th>Example</th>
</tr>
<tr>
<td><code>centre-title-and-time</code></td>
<td><img src="./docs/screenshots/template_centre-title-and-time.png" width=500px></td>
</tr>
<tr>
<td><code>left-panel-info</code></td>
<td><img src="./docs/screenshots/template_left-panel-info.png" width=500px></td>
</td>
<tr>
<td><code>left-panel-next-five</code></td>
<td><img src="./docs/screenshots/template_left-panel-next-five.png" width=500px></td>
</td>
</tr>
</table>

## 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?
2 changes: 1 addition & 1 deletion apps/backend/src/canvas2video/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type makeSceneFunction = (
canvas: fabric.StaticCanvas,
anim: gsap.core.Timeline,
compose: () => void,
) => void;
) => Promise<void>;

export interface RendererConfig {
width: number;
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ addFormats(ajv);

type ChannelId = string;
export interface ChannelConfig {
template: "centre-title-and-time";
template: string;
backgroundContent: "*" | string[];
}

Expand Down Expand Up @@ -54,7 +54,7 @@ const schema: JSONSchemaType<AppConfig> = {
properties: {
template: {
type: "string",
enum: ["centre-title-and-time"],
// enum: ["centre-title-and-time"],
},
backgroundContent: {
oneOf: [
Expand Down
21 changes: 12 additions & 9 deletions apps/backend/src/jobs/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
fetchAndParseXmlTv,
getNextProgrammeForChannel,
getNextProgrammesForChannel,
getValueForConfiguredLang,
} from "../xmltv/index.js";
import {
Expand All @@ -12,7 +12,7 @@ import {
type Result,
} from "../result/index.js";
import {
createOverlayConfigFromProgramme,
createProgrammeInfoFromProgrammes,
makeVideo,
} from "../video-generator/index.js";
import { appConfig, type ChannelConfig } from "../config/app.js";
Expand Down Expand Up @@ -67,14 +67,17 @@ const channelTask = async (
programmes: XmltvProgramme[],
channelConfig: ChannelConfig,
): ReturnType<typeof makeVideo> => {
const currentProgramme = getNextProgrammeForChannel(channel, programmes);
if (isFailure(currentProgramme)) {
return failure("Failed to get next programme", currentProgramme.error);
const nextProgrammes = getNextProgrammesForChannel(channel, programmes);
if (isFailure(nextProgrammes)) {
return failure("Failed to get next programme", nextProgrammes.error);
}

const overlay = createOverlayConfigFromProgramme(currentProgramme.result);
if (isFailure(overlay)) {
return failure("Failed to get overlay config", overlay.error);
const programmeInfo = createProgrammeInfoFromProgrammes(
nextProgrammes.result,
);

if (isFailure(programmeInfo)) {
return failure("Failed to get overlay config", programmeInfo.error);
}

const backgroundContentPath =
Expand All @@ -90,7 +93,7 @@ const channelTask = async (

return makeVideo({
template: template.result,
overlay: overlay.result,
programmes: programmeInfo.result,
outputDir: appConfig.outputFolder,
outputFileName: `channel-${channel.id}.mp4`,
length: 20,
Expand Down
80 changes: 47 additions & 33 deletions apps/backend/src/video-generator/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { XmltvProgramme } from "@iptv/xmltv";
import type { FabricTemplate } from "bumpgen-shared/types";
import type { FabricTemplate, ProgrammeInfo } from "bumpgen-shared/types";
import { isNotUndefined } from "bumpgen-shared/utils";

import {
failure,
isFailure,
Expand All @@ -22,15 +24,6 @@ import { logDebug, logError } from "../logger/index.js";
import { existsSync } from "node:fs";
import { Fonts } from "../fonts/index.js";

export interface VideoOverlay {
title: string;
subtitle?: string;
episode?: string;
description?: string;
iconUrl?: string;
start?: Date;
}

export interface ChannelInfo {
id: string;
name?: string;
Expand All @@ -44,7 +37,7 @@ export interface VideoBackground {

export interface VideoOptions {
channelInfo: ChannelInfo;
overlay: VideoOverlay;
programmes: [ProgrammeInfo, ...ProgrammeInfo[]];
background?: VideoBackground;
outputDir: string;
outputFileName: string;
Expand Down Expand Up @@ -72,7 +65,7 @@ const getBackgroundVideoStartAndEnd = (
};

const getProgrammeId = (options: VideoOptions) =>
`${options.overlay.title}-${options.overlay.episode}`;
`${options.programmes[0].title}-${options.programmes[0].episode}`;

const shouldGenerateVideo = async (
options: VideoOptions,
Expand Down Expand Up @@ -134,8 +127,8 @@ export const makeVideo = async (
width,
height,
fps: 1,
makeScene: (fabric, canvas, anim, compose) => {
options.template(options.overlay, {
makeScene: async (fabric, canvas, anim, compose) => {
await options.template(options.programmes, {
getFontProperties: (...args) => Fonts.getFontProperties(...args),
convertX: (val: number) => val * width,
convertY: (val: number) => val * height,
Expand Down Expand Up @@ -183,25 +176,46 @@ export const makeVideo = async (
}
};

export const createOverlayConfigFromProgramme = (
programme: XmltvProgramme,
): Result<VideoOverlay> => {
const title = getValueForConfiguredLang(programme.title);
if (isFailure(title)) {
return failure("Title required to create overlay");
export const createProgrammeInfoFromProgrammes = (
programmes: [XmltvProgramme, ...XmltvProgramme[]],
): Result<[ProgrammeInfo, ...ProgrammeInfo[]]> => {
const createProgrammeInfo = (
programme: XmltvProgramme,
): Result<ProgrammeInfo> => {
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;
}

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,
iconUrl: unwrap(iconUrl),
});
return success([
firstItemResult.result,
...programmes
.slice(1)
.map(createProgrammeInfo)
.map(unwrap)
.filter(isNotUndefined),
]);
};
19 changes: 12 additions & 7 deletions apps/backend/src/xmltv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export const fetchAndParseXmlTv = async (
try {
const response = await fetch(path);
const body = await response.text();
const xmlTv = parseXmltv(body);
// Fix a weird bug where the apostrophe specifically isn't decoded
const bodyFixed = body.replaceAll("&#39;", "'");
const xmlTv = parseXmltv(bodyFixed);

const { channels, programmes } = xmlTv;
if (!channels) {
Expand All @@ -40,29 +42,32 @@ export const fetchAndParseXmlTv = async (
}
};

export const getNextProgrammeForChannel = (
export const getNextProgrammesForChannel = (
channel: XmltvChannel,
programmes: XmltvProgramme[],
): Result<XmltvProgramme> => {
): Result<[XmltvProgramme, ...XmltvProgramme[]]> => {
const forChannel = programmes.filter((p) => p.channel === channel.id);
const sorted = [...forChannel].sort((a, b) => {
return a.start.getTime() - b.start.getTime();
});

const current = sorted.find((programme) => {
const nextUpIndex = sorted.findIndex((programme) => {
if (Date.now() < programme.start.getTime()) return true;
else return false;
});

if (current) {
return success(current);
} else {
if (nextUpIndex === -1) {
logDebug("Failed to find current program for channel " + channel.id);
return failure(
"Failed to find current programme playing on channel with id " +
channel.id,
);
}

return success([
programmes[nextUpIndex]!,
...programmes.slice(nextUpIndex + 1),
]);
};

export const getOnScreenEpisodeNumber = (
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/template_left-panel-info.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,10 @@ export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.strict,
tseslint.configs.stylistic,
{
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
},
},
prettierConfig,
);
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"version": "0.0.1",
"type": "module",
"workspaces": ["apps/backend", "packages/templates", "packages/shared", "packages/example-plugin"],
"scripts": {},
"scripts": {
"dev": "npm run build --workspaces && npm run dev --workspace apps/backend"
},
"keywords": [],
"author": "",
"license": "ISC",
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false
}
}
}
9 changes: 6 additions & 3 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts"
"./types": "./types.ts",
"./utils": {
"import": "./dist/src/utils.js",
"types": "./src/utils.ts"
}
},
"scripts": {
"build": "echo \"Skipping build for 'shared' package\"",
"build": "swc ./src/ -d ./dist --config-file .swcrc",
"tsc": "tsc -p tsconfig.json",
"lint": "eslint ./src",
"lint:fix": "eslint ./src --fix",
Expand Down
2 changes: 0 additions & 2 deletions packages/shared/src/index.ts

This file was deleted.

13 changes: 13 additions & 0 deletions packages/shared/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Can be used in Array.filter to filter all undefined items out in a way
* typecsript understands
*
* @example
* ```ts
* const myArrayWithUndefined = [1, undefined, 2];
* const myArray: number[] = myArrayWithUndefined.filter(isNotUndefined);
* // myArray = [1, 2]
* ```
*/
export const isNotUndefined = <T>(arg: T | undefined): arg is T =>
arg !== undefined;
Loading
Loading