Overview
Managing IPTV streams requires validating that channels are online, parsing M3U playlists, and detecting duplicates. This guide builds a management system using FFmpeg for stream probing and Next.js for the interface.
Key Capabilities
- Parse M3U/M3U8 playlist files with metadata extraction
- Validate streams using FFmpeg probe (codec, resolution, bitrate)
- Detect duplicate channels across multiple playlists
- Automated health monitoring with configurable intervals
Requirements
| Component | Purpose |
|---|---|
| FFmpeg | Stream probing and validation |
| Node.js 18+ | Runtime |
| Next.js 15+ | Web interface |
# Install FFmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# macOS
brew install ffmpeg
# Verify installation
ffmpeg -versionProcess
Step 1: M3U Playlist Parser
M3U playlists use the #EXTINF directive for metadata:
// lib/m3u-parser.ts
interface Channel {
id: string;
name: string;
url: string;
group: string;
logo?: string;
language?: string;
tvgId?: string;
}
export function parseM3U(content: string): Channel[] {
const lines = content.split("\n").map((l) => l.trim());
const channels: Channel[] = [];
let currentMeta: Partial<Channel> = {};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("#EXTINF")) {
// Parse metadata: #EXTINF:-1 tvg-id="..." group-title="..." tvg-logo="...",Channel Name
const attrs: Record<string, string> = {};
const attrRegex = /(\w[\w-]*)="([^"]*)"/g;
let match;
while ((match = attrRegex.exec(line)) !== null) {
attrs[match[1]] = match[2];
}
// Channel name is after the last comma
const nameMatch = line.match(/,(.+)$/);
currentMeta = {
name: nameMatch?.[1]?.trim() || "Unknown",
group: attrs["group-title"] || "Uncategorized",
logo: attrs["tvg-logo"],
tvgId: attrs["tvg-id"],
language: attrs["tvg-language"],
};
} else if (line.startsWith("http") && currentMeta.name) {
channels.push({
id: generateId(currentMeta.name, line),
url: line,
...currentMeta,
} as Channel);
currentMeta = {};
}
}
return channels;
}
function generateId(name: string, url: string): string {
const hash = Buffer.from(`${name}:${url}`).toString("base64url").slice(0, 12);
return hash;
}Step 2: FFmpeg Stream Validation
// lib/stream-validator.ts
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
interface StreamInfo {
valid: boolean;
codec?: string;
resolution?: string;
bitrate?: number;
fps?: number;
error?: string;
responseTimeMs: number;
}
export async function validateStream(
url: string,
timeoutSec: number = 10
): Promise<StreamInfo> {
const startTime = Date.now();
try {
const { stdout } = await execFileAsync("ffprobe", [
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-show_format",
"-timeout", String(timeoutSec * 1000000), // microseconds
url,
], { timeout: (timeoutSec + 5) * 1000 });
const data = JSON.parse(stdout);
const videoStream = data.streams?.find(
(s: any) => s.codec_type === "video"
);
return {
valid: true,
codec: videoStream?.codec_name,
resolution: videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined,
bitrate: data.format?.bit_rate
? parseInt(data.format.bit_rate)
: undefined,
fps: videoStream?.r_frame_rate
? eval(videoStream.r_frame_rate)
: undefined,
responseTimeMs: Date.now() - startTime,
};
} catch (err: any) {
return {
valid: false,
error: err.message?.slice(0, 200),
responseTimeMs: Date.now() - startTime,
};
}
}Step 3: Duplicate Detection
// lib/duplicate-detector.ts
import { Channel } from "./m3u-parser";
interface DuplicateGroup {
channels: Channel[];
matchType: "exact-url" | "similar-name" | "same-tvg-id";
}
export function findDuplicates(channels: Channel[]): DuplicateGroup[] {
const duplicates: DuplicateGroup[] = [];
// 1. Exact URL matches
const urlMap = new Map<string, Channel[]>();
for (const ch of channels) {
const normalized = ch.url.replace(/\?.+$/, ""); // Strip query params
const existing = urlMap.get(normalized) || [];
existing.push(ch);
urlMap.set(normalized, existing);
}
for (const group of urlMap.values()) {
if (group.length > 1) {
duplicates.push({ channels: group, matchType: "exact-url" });
}
}
// 2. Same TVG-ID
const tvgMap = new Map<string, Channel[]>();
for (const ch of channels) {
if (!ch.tvgId) continue;
const existing = tvgMap.get(ch.tvgId) || [];
existing.push(ch);
tvgMap.set(ch.tvgId, existing);
}
for (const group of tvgMap.values()) {
if (group.length > 1) {
duplicates.push({ channels: group, matchType: "same-tvg-id" });
}
}
// 3. Similar names (Jaccard similarity)
const nameGroups = findSimilarNames(channels, 0.7);
for (const group of nameGroups) {
if (group.length > 1) {
duplicates.push({ channels: group, matchType: "similar-name" });
}
}
return duplicates;
}
function findSimilarNames(channels: Channel[], threshold: number): Channel[][] {
const groups: Channel[][] = [];
const used = new Set<number>();
for (let i = 0; i < channels.length; i++) {
if (used.has(i)) continue;
const group = [channels[i]];
for (let j = i + 1; j < channels.length; j++) {
if (used.has(j)) continue;
const similarity = jaccardSimilarity(
channels[i].name.toLowerCase(),
channels[j].name.toLowerCase()
);
if (similarity >= threshold) {
group.push(channels[j]);
used.add(j);
}
}
if (group.length > 1) {
used.add(i);
groups.push(group);
}
}
return groups;
}
function jaccardSimilarity(a: string, b: string): number {
const setA = new Set(a.split(/\s+/));
const setB = new Set(b.split(/\s+/));
const intersection = new Set([...setA].filter((x) => setB.has(x)));
const union = new Set([...setA, ...setB]);
return intersection.size / union.size;
}Step 4: Health Monitor
// lib/health-monitor.ts
import { validateStream } from "./stream-validator";
interface HealthResult {
channelId: string;
url: string;
status: "online" | "offline" | "degraded";
responseTimeMs: number;
checkedAt: string;
}
export async function checkHealth(
channels: { id: string; url: string }[],
concurrency: number = 5
): Promise<HealthResult[]> {
const results: HealthResult[] = [];
// Process in batches to avoid overwhelming the network
for (let i = 0; i < channels.length; i += concurrency) {
const batch = channels.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(async (ch) => {
const info = await validateStream(ch.url, 8);
return {
channelId: ch.id,
url: ch.url,
status: info.valid
? info.responseTimeMs > 5000 ? "degraded" as const : "online" as const
: "offline" as const,
responseTimeMs: info.responseTimeMs,
checkedAt: new Date().toISOString(),
};
})
);
results.push(...batchResults);
}
return results;
}Stream Quality Tiers
| Tier | Resolution | Bitrate | Use Case |
|---|---|---|---|
| SD | 720x480 | 1-2 Mbps | Mobile, low bandwidth |
| HD | 1280x720 | 2-5 Mbps | Standard viewing |
| FHD | 1920x1080 | 5-10 Mbps | Desktop, smart TV |
| 4K | 3840x2160 | 15-25 Mbps | Large displays |
Key Takeaways
- FFprobe (
ffprobe) is more reliable than HTTP HEAD checks for stream validation - Batch concurrent validations (5-10 at a time) to avoid network saturation
- Jaccard similarity catches duplicate channels with slightly different names
- Store validation results for trend analysis — streams often degrade before failing
- Always set timeouts on FFmpeg operations to prevent hanging processes