diff --git a/.env.example b/.env.example index fb4722b..1f3b145 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/.github/workflows/main.yml b/.github/workflows/master.yml similarity index 98% rename from .github/workflows/main.yml rename to .github/workflows/master.yml index 36294a3..76a85c4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/master.yml @@ -1,4 +1,4 @@ -name: Main Branch Release +name: Master Branch Release on: release: diff --git a/Dockerfile b/Dockerfile index 81ff7ba..01eb92f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 " LABEL description="A Discord IPTV streaming bot." LABEL org.opencontainers.image.source="https://github.com/zbejas/orbiscast" @@ -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"] diff --git a/README.md b/README.md index 09f8d92..5406709 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/modules/embeds/programme.ts b/src/modules/embeds/programme.ts index 2cff50e..5b80634 100644 --- a/src/modules/embeds/programme.ts +++ b/src/modules/embeds/programme.ts @@ -222,7 +222,6 @@ export class ProgrammeEmbedProcessor extends BaseEmbedProcessor : 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)}...` @@ -239,7 +238,7 @@ export class ProgrammeEmbedProcessor extends BaseEmbedProcessor : `${programme.title}${episodeText}`; dateEmbed.addFields({ - name: `${startTime} - ${stopTime}: ${showTitle}`, + name: `${startTime}: ${showTitle}`, value: description }); }); diff --git a/src/modules/iptv/parsers/playlist-parser.ts b/src/modules/iptv/parsers/playlist-parser.ts index f21c6c2..830d3d3 100644 --- a/src/modules/iptv/parsers/playlist-parser.ts +++ b/src/modules/iptv/parsers/playlist-parser.ts @@ -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="([^"]+)"/); @@ -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] : ''; @@ -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="([^"]+)"/); diff --git a/src/modules/iptv/parsers/xmltv-parser.ts b/src/modules/iptv/parsers/xmltv-parser.ts index 700c006..54f083c 100644 --- a/src/modules/iptv/parsers/xmltv-parser.ts +++ b/src/modules/iptv/parsers/xmltv-parser.ts @@ -51,6 +51,14 @@ export async function extractXMLTVData(filePath: string): Promise { }); } + // Build channel name map for title cleanup + const channelNameMap = new Map(); + 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) { @@ -58,7 +66,7 @@ export async function extractXMLTVData(filePath: string): Promise { 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}`); @@ -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; + } } } @@ -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): 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]); diff --git a/src/modules/streaming/index.ts b/src/modules/streaming/index.ts index 4452b12..f0acfa8 100644 --- a/src/modules/streaming/index.ts +++ b/src/modules/streaming/index.ts @@ -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; diff --git a/src/utils/config.ts b/src/utils/config.ts index 75828b5..a7aaf5f 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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'); @@ -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';