Skip to content
Merged

Dev #32

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ba64c49
fix: enhance alternative format parsing to include tvg-chno attribute…
zbejas Dec 29, 2025
711069a
fix: improve channel name extraction to prioritize non-numeric displa…
zbejas Dec 29, 2025
374f204
fix: enhance programme entry building to include channel name cleanup
zbejas Dec 29, 2025
b83f470
fix: enhance startStreaming to detect HLS streams and adjust ffmpeg s…
zbejas Dec 29, 2025
e581d25
fix: add DISABLE_TRANSCODE configuration option and update startStrea…
zbejas Dec 29, 2025
e3a1111
fix: enhance startStreaming with debug logging and HLS detection impr…
zbejas Dec 29, 2025
3fcca8c
fix: enhance startStreaming to transform stream URL for better FFmpeg…
zbejas Dec 29, 2025
f41342e
fix: streamline startStreaming by removing URL transformation and enh…
zbejas Dec 29, 2025
ba431fd
fix: optimize FFmpeg flags in startStreaming for improved stream hand…
zbejas Dec 29, 2025
1a3ece6
fix: remove debug logging for stream URL and transcode status in star…
zbejas Dec 29, 2025
5e794da
fix: add buffer size calculation and custom FFmpeg flags for improved…
zbejas Dec 29, 2025
e61e978
fix: update logging in startStreaming and enhance FFmpeg flags for im…
zbejas Dec 29, 2025
e37704d
feat: add GitHub Actions workflow for Docker image release on publish
zbejas Jan 1, 2026
65b5cbf
fix: correct workflow name from 'Main Branch Release' to 'Master Bran…
zbejas Jan 1, 2026
fbaaf61
refactor: streamline Dockerfile by consolidating build and runtime st…
zbejas Jan 1, 2026
fb8ad03
refactor: optimize Dockerfile by separating build and runtime stages …
zbejas Jan 1, 2026
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ XMLTV="http://example.com/xmltv/guide.xml"
# MINIMIZE_LATENCY=true
# BITRATE_VIDEO=5000
# BITRATE_VIDEO_MAX=7500
# DISABLE_TRANSCODE=false

# Timezone configuration
#TZ="UTC"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Main Branch Release
name: Master Branch Release

on:
release:
Expand Down
33 changes: 15 additions & 18 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
# Build stage
FROM node:lts-alpine3.23 AS builder

RUN apk update && \
apk add --no-cache python3 make g++
RUN apk add --no-cache python3 make g++

WORKDIR /app
COPY package.json ./
RUN npm install

COPY tsconfig.json ./
COPY package.json tsconfig.json ./
COPY src ./src
RUN npm run build

RUN npm install && npm run build

# Runtime stage
FROM node:lts-alpine3.23 AS runtime
# Had to switch from bun to node due to zeromq not being supported in bun yet.
FROM node:lts-alpine3.23

RUN apk add --no-cache ffmpeg

WORKDIR /app

COPY package.json ./
RUN npm install --omit=dev

COPY --from=builder /app/dist ./dist

# Metadata labels
LABEL maintainer="Zbejas <info@zbejas.io>"
LABEL description="A Discord IPTV streaming bot."
LABEL org.opencontainers.image.source="https://github.com/zbejas/orbiscast"
Expand All @@ -24,14 +31,4 @@ LABEL org.opencontainers.image.authors="Zbejas"
LABEL org.opencontainers.image.licenses="GPL-3.0"
LABEL org.opencontainers.image.title="Orbiscast"

RUN apk update && \
apk add --no-cache ffmpeg python3 make g++

WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev && \
apk del python3 make g++

COPY --from=builder /app/dist ./dist

CMD ["npm", "run", "start:prod"]
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,19 +116,20 @@ The application uses the following environment variables, which should be define

### System and IPTV Configuration

| Variable | Description | Example/Default | Required |
|--------------------|--------------------------------------------------|------------------------------------------|----------|
| `PLAYLIST` | URL to the M3U playlist. | `http://example.com/m3u/playlist.m3u` | ✔ |
| `XMLTV` | URL to the XMLTV guide. | `http://example.com/xmltv/guide.xml` | ✘ |
| `REFRESH_IPTV` | Interval in minutes to refresh the IPTV data. | `1440` | ✘ |
| `RAM_CACHE` | Whether to use RAM for caching. | `true` | ✘ |
| `CACHE_DIR` | Directory for cache storage. | `../cache` | ✘ |
| `DEBUG` | Enable debug mode. | `false` | ✘ |
| `DEFAULT_STREAM_TIMEOUT` | Default stream timeout (when alone in channel) in minutes. | `10` | ✘ |
| `TZ` | Timezone for the container. Example: `Europe/Ljubljana` | `UTC` | ✘ |
| `MINIMIZE_LATENCY` | Minimize latency for the stream. | `true` | ✘ |
| `BITRATE_VIDEO` | Video bitrate in Kbps. | `5000` | ✘ |
| `BITRATE_VIDEO_MAX`| Maximum video bitrate in Kbps. | `7500` | ✘ |
| Variable | Description | Example/Default | Required |
|--------------------------|-----------------------------------------------------------------------|---------------------------------------|----------|
| `PLAYLIST` | URL to the M3U playlist. | `http://example.com/m3u/playlist.m3u` | ✔ |
| `XMLTV` | URL to the XMLTV guide. | `http://example.com/xmltv/guide.xml` | ✘ |
| `REFRESH_IPTV` | Interval in minutes to refresh the IPTV data. | `1440` | ✘ |
| `RAM_CACHE` | Whether to use RAM for caching. | `true` | ✘ |
| `CACHE_DIR` | Directory for cache storage. | `../cache` | ✘ |
| `DEBUG` | Enable debug mode. | `false` | ✘ |
| `DEFAULT_STREAM_TIMEOUT` | Default stream timeout (when alone in channel) in minutes. | `10` | ✘ |
| `TZ` | Timezone for the container. Example: `Europe/Ljubljana` | `UTC` | ✘ |
| `MINIMIZE_LATENCY` | Minimize latency for the stream. | `true` | ✘ |
| `BITRATE_VIDEO` | Video bitrate in Kbps. | `5000` | ✘ |
| `BITRATE_VIDEO_MAX` | Maximum video bitrate in Kbps. | `7500` | ✘ |
| `DISABLE_TRANSCODE` | Disable transcoding and pass stream through directly. | `false` | ✘ |

> [!TIP]
> There is a bunch of IPTV providers online. I recommend using a tool like [Threadfin](https://github.com/Threadfin/Threadfin) or [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr) to sort out your IPTV channels. You can find public M3U playlists [here](https://github.com/iptv-org/iptv). More info on IPTV can be found [here](https://github.com/iptv-org/awesome-iptv).
Expand Down
3 changes: 1 addition & 2 deletions src/modules/embeds/programme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ export class ProgrammeEmbedProcessor extends BaseEmbedProcessor<ProgrammeEntry>
: new Date(programme.stop_timestamp ? programme.stop_timestamp * 1000 : Date.now());

const startTime = startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
const stopTime = stopDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
const description = typeof programme.description === 'string'
? (programme.description.length > 100
? `${programme.description.substring(0, 100)}...`
Expand All @@ -239,7 +238,7 @@ export class ProgrammeEmbedProcessor extends BaseEmbedProcessor<ProgrammeEntry>
: `${programme.title}${episodeText}`;

dateEmbed.addFields({
name: `${startTime} - ${stopTime}: ${showTitle}`,
name: `${startTime}: ${showTitle}`,
value: description
});
});
Expand Down
3 changes: 3 additions & 0 deletions src/modules/iptv/parsers/playlist-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function parseAlternativeFormat(line: string): ChannelEntry | null {
// Extract attributes flexibly without requiring specific order
const tvgIdMatch = line.match(/tvg-id="([^"]+)"/);
const tvgNameMatch = line.match(/tvg-name="([^"]+)"/);
const tvgChnoMatch = line.match(/tvg-chno="([^"]+)"/);
const tvgLogoMatch = line.match(/tvg-logo="([^"]+)"/);
const groupTitleMatch = line.match(/group-title="([^"]+)"/);

Expand All @@ -70,6 +71,7 @@ function parseAlternativeFormat(line: string): ChannelEntry | null {
}

const tvg_id = tvgIdMatch ? tvgIdMatch[1] : '';
const tvg_chno = tvgChnoMatch ? tvgChnoMatch[1] : '';
const tvg_name = tvgNameMatch ? tvgNameMatch[1] : (channelName || tvg_id);
const tvg_logo = tvgLogoMatch ? tvgLogoMatch[1] : '';
const group_title = groupTitleMatch ? groupTitleMatch[1] : '';
Expand Down Expand Up @@ -110,6 +112,7 @@ function parseFlexibleFormat(line: string): ChannelEntry | null {

// Extract available attributes
const tvgIdMatch = line.match(/tvg-id="([^"]+)"/);
const tvgChnoMatch = line.match(/tvg-chno="([^"]+)"/);
const tvgLogoMatch = line.match(/tvg-logo="([^"]+)"/);
const groupTitleMatch = line.match(/group-title="([^"]+)"/);

Expand Down
31 changes: 24 additions & 7 deletions src/modules/iptv/parsers/xmltv-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,22 @@ export async function extractXMLTVData(filePath: string): Promise<ParsedXMLTV> {
});
}

// Build channel name map for title cleanup
const channelNameMap = new Map<string, string>();
output.channels.forEach(ch => {
if (ch.tvg_id && ch.tvg_name) {
channelNameMap.set(ch.tvg_id, ch.tvg_name);
}
});

// Process programmes if available
const programmeList = xmlData.tv.programme || [];
if (programmeList.length > 0) {
logger.info(`Processing ${programmeList.length} programme entries`);

programmeList.forEach((programmeNode: any) => {
try {
output.programmes.push(buildProgrammeEntry(programmeNode));
output.programmes.push(buildProgrammeEntry(programmeNode, channelNameMap));
} catch (err) {
const programmeTitle = extractTextContent(programmeNode['title']?.[0]) || 'unknown';
logger.error(`Programme extraction failed for "${programmeTitle}": ${err}`);
Expand Down Expand Up @@ -94,14 +102,15 @@ function extractChannelData(channel: any): ChannelEntry {
for (const nameElement of channel['display-name']) {
const displayName = extractTextContent(nameElement);

// Use first display name as primary channel name
if (!channelName) {
channelName = displayName;
}
// Identify numeric-only display names as channel numbers
if (/^\d+$/.test(displayName) && !channelNum) {
channelNum = displayName;
}
// Use first non-numeric display name as primary channel name
// This prevents "1 One Piece" format from being used when "One Piece" is available
else if (!channelName || /^\d+\s/.test(channelName)) {
channelName = displayName;
}
}
}

Expand All @@ -125,12 +134,20 @@ function extractChannelData(channel: any): ChannelEntry {
* Builds a structured programme entry from raw XMLTV programme data.
*
* @param programme - Raw programme data from XMLTV
* @param channelNameMap - Map of channel IDs to names for title cleanup
* @returns Structured programme entry
* @throws Error if programme is missing required fields
*/
function buildProgrammeEntry(programme: any): ProgrammeEntry {
function buildProgrammeEntry(programme: any, channelNameMap: Map<string, string>): ProgrammeEntry {
// Extract text content fields
const title = extractTextContent(programme['title']?.[0]);
let title = extractTextContent(programme['title']?.[0]);

// Strip duplicate channel name prefix if present (e.g., "One Piece: One Piece: The Movie" -> "One Piece: The Movie")
const channelId = programme.$.channel;
const channelName = channelNameMap.get(channelId);
if (channelName && title.startsWith(`${channelName}: `)) {
title = title.substring(channelName.length + 2);
}
const description = extractTextContent(programme['desc']?.[0]);
const category = extractTextContent(programme['category']?.[0]);
const subtitle = extractTextContent(programme['sub-title']?.[0]);
Expand Down
14 changes: 13 additions & 1 deletion src/modules/streaming/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,25 @@ export async function startStreaming(channelEntry: ChannelEntry) {
logger.info(`Stopping any possible existing stream.`);
await stopStreaming();

logger.info(`Attempting to stream: ${channelEntry.url}`);

// Calculate buffer size as 3x the video bitrate for smooth livestreaming
const bufferSize = config.BITRATE_VIDEO * 3;

const { command, output } = prepareStream(channelEntry.url, {
noTranscoding: false,
noTranscoding: config.DISABLE_TRANSCODE,
minimizeLatency: config.MINIMIZE_LATENCY,
bitrateVideo: config.BITRATE_VIDEO,
bitrateVideoMax: config.BITRATE_VIDEO_MAX,
videoCodec: Utils.normalizeVideoCodec("H264"),
h26xPreset: "veryfast",
customFfmpegFlags: [
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-buffer_size', `${bufferSize}k`,
'-max_delay', '500000'
],
}, abortController.signal);

currentChannelEntry = channelEntry;
Expand Down
2 changes: 2 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Config {
MINIMIZE_LATENCY: boolean;
BITRATE_VIDEO: number;
BITRATE_VIDEO_MAX: number;
DISABLE_TRANSCODE: boolean;

constructor() {
logger.info('Loading environment variables');
Expand All @@ -41,6 +42,7 @@ class Config {
this.MINIMIZE_LATENCY = env.MINIMIZE_LATENCY?.trim().toLowerCase() !== 'false';
this.BITRATE_VIDEO = parseInt(env.BITRATE_VIDEO?.trim() || '5000');
this.BITRATE_VIDEO_MAX = parseInt(env.BITRATE_VIDEO_MAX?.trim() || '7500');
this.DISABLE_TRANSCODE = env.DISABLE_TRANSCODE?.trim().toLowerCase() === 'true';

// Debug configuration
this.DEBUG = env.DEBUG?.trim().toLowerCase() === 'true';
Expand Down