HOWTOIntermediate

IPTV Stream Validation and M3U Playlist Management with FFmpeg

Build a robust IPTV stream management system using FFmpeg for validation, M3U playlist parsing, EPG integration, and automated health monitoring of live...

Dylan H.

Software Engineering

February 6, 2026
5 min read
IPTV Stream Validation and M3U Playlist Management with FFmpeg

Prerequisites

  • FFmpeg installed
  • Node.js 18+
  • Basic understanding of streaming protocols

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

ComponentPurpose
FFmpegStream 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 -version

Process

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

TierResolutionBitrateUse Case
SD720x4801-2 MbpsMobile, low bandwidth
HD1280x7202-5 MbpsStandard viewing
FHD1920x10805-10 MbpsDesktop, smart TV
4K3840x216015-25 MbpsLarge 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