Parse and normalize IPTV M3U/M3U8 and Xtream get.php playlists for Open IPTV. Use for playlist fetching, channel classification, group filters, search normalization, huge-list performance, and parse diagnostics. Covers BOM/CRLF, Latin-1, attributes, duplicates, relative URLs, and credential redaction.
Install
npx skillscat add lsunol/my-iptv-player/iptv-playlists Install via the SkillsCat registry.
IPTV playlists
This skill is about turning a user-supplied playlist into a fast, searchable
channel list without leaking credentials or blocking the UI.
Non-negotiables
- Parse off the main thread, line by line. A large list can be several
megabytes and tens of thousands of entries.readText()+ regex on the whole
file is a bug. - Treat the playlist as untrusted input. It can be HTML, a stream manifest, a
truncated file, or 100k malformed lines. - Never log the playlist URL, channel URLs,
username,password,token, or
provider host in full. UseredactUrl(). - Show the user what happened: bytes read, entries parsed, groups found, and
the first parse warning. Silent failure is not acceptable. - Do not silently deduplicate. Duplicate names with
FHD/HEVC/HDRare
normal and sometimes the only working variant.
Accepted inputs
| Input | Example | Handling |
|---|---|---|
| Raw M3U/M3U8 URL | https://host/list.m3u |
fetch, parse as text |
| Xtream HLS | .../get.php?username=U&password=P&type=m3u_plus&output=m3u8 |
fetch, parse; channel URLs end .m3u8 |
| Xtream TS | same with output=ts |
fetch, parse; channel URLs end .ts |
| Local file | local/list.m3u |
debug/testing only |
| Single stream URL | .../live/U/P/123.m3u8 |
not a playlist; explain and offer to play it directly |
The app only asks for the playlist URL. Do not ask for username/password
separately in v1; they are already inside the Xtream URL.
Canonical model
enum class SourceType { LIVE, MOVIE, SERIES, OTHER }
data class Channel(
val id: String, // stable UI key: hash(url + "|" + name)
val name: String, // display name, trimmed
val url: String, // absolute, resolved against playlist URL
val group: String?, // group-title or #EXTGRP
val logoUrl: String?, // tvg-logo, may be empty
val tvgId: String?, // tvg-id
val tvgName: String?, // tvg-name
val sourceType: SourceType,
val searchKey: String, // normalized once, see below
val qualityTag: String? // extracted FHD/HD/HEVC/HDR/4K, optional
)A ParseResult should carry channels, warnings, groups, bytesRead, andelapsedMs. The ViewModel exposes the counts to the UI.
Fetching
- OkHttp with a 10 MB disk cache, 20 s connect / 45 s read timeout, redirects
enabled, and a browser-likeUser-Agent. - Send
Accept: application/x-mpegURL, application/vnd.apple.mpegurl, text/plain, */*. - Use the final response URL as the base for relative channel URLs.
- Read the body as a stream. If
Content-Lengthis known, report progress. - Cancel the OkHttp
Callwhen the user replaces the URL or leaves the screen. - A 200 HTML page is not a playlist. Detect
<!DOCTYPE,<html, or a JSON
error body and report "the provider returned a web page, not a playlist". - A media playlist (
#EXT-X-TARGETDURATION,#EXT-X-STREAM-INF, no#EXTINF
channel entries) is a stream, not a channel list. Report that distinction.
Encoding
- Honor the
charsetinContent-Type. - Otherwise decode UTF-8.
- If decoding produces replacement characters, retry as ISO-8859-1.
- Strip a UTF-8 BOM (
\uFEFF) from the first line. - Normalize
\r\nand\r;BufferedReader.readLine()already handles both.
Line parser
State machine, not a regex over the whole file:
var pending: ExtInf? = null
var lineNo = 0
while (true) {
val raw = reader.readLine() ?: break
lineNo++
val line = raw.trim().removePrefix("\uFEFF")
when {
line.isEmpty() -> Unit
line.startsWith("#EXTM3U") -> Unit
line.startsWith("#EXTINF:") -> pending = parseExtInf(line, lineNo)
line.startsWith("#EXTGRP:") ->
pending = pending?.copy(group = line.substringAfter(':').trim().ifBlank { null })
line.startsWith("#EXTVLCOPT:") -> Unit // v1: ignore, optionally warn
line.startsWith("#") -> Unit // comments, VLC/Kodi props
else -> {
val url = resolveUrl(line, baseUrl)
if (pending == null) {
warnings += Warning(lineNo, "URL without #EXTINF; ignored")
} else if (!url.isHttpUrl()) {
warnings += Warning(lineNo, "Non-HTTP channel URL; ignored")
} else {
emit(buildChannel(pending, url))
}
pending = null
}
}
if (lineNo % 500 == 0) onProgress(lineNo, count)
}parseExtInf rules:
- Format:
#EXTINF:<duration> [key="value" ...],<display name>. - Find the comma that separates attributes from the display name outside
quotes. Do not usesplit(","); names and logo URLs can contain commas. - Parse attributes with
Regex("""([A-Za-z0-9_-]+)="([^"]*)""""). - If the display name is blank, fall back to
tvg-name, thentvg-id, then"Canal sin nombre". - Quote-aware comma scan:
fun splitExtInf(line: String): Pair<String, String> {
var inQuotes = false
for (i in line.indices) {
when (line[i]) {
'"' -> inQuotes = !inQuotes
',' -> if (!inQuotes) return line.substring(0, i) to line.substring(i + 1)
}
}
return line to ""
}- Tolerate unquoted attributes (
tvg-id=abc) as a fallback, but prefer quoted. #EXTGRP:sets the group ifgroup-titleis absent.- A second
#EXTINFbefore a URL replaces the pending entry and adds a warning.
Classification
Primary signal: URL path. Xtream paths look like /live/U/P/123.m3u8,/movie/U/P/456.mp4, /series/U/P/789.mkv.
fun classify(url: String, group: String?): SourceType {
val path = url.substringAfter("://", "").substringAfter('/', "").lowercase()
return when {
"/live/" in path -> LIVE
"/movie/" in path -> MOVIE
"/series/" in path -> SERIES
else -> fallbackFromGroup(group)
}
}Fallback from group, translated where useful:
- Movie:
VOD,PELICULA,PELÍCULA,MOVIE,CINE,FILM. - Series:
SERIE,SERIES,NOVELA,ANIME,DOCUSERIE. - Live:
TV,LIVE,EN VIVO,DIRECTO,CANAL,DEPORTE,FUTBOL,NOTICIA,INFANTIL,MUSICA. - Otherwise
OTHER. Keep it; do not drop unknown entries.
Use a synthetic fixture (with live/movie/series/other entries) for regression
checks. Never commit a real provider list.
Search normalization
Precompute once per channel:
fun searchKey(input: String): String =
Normalizer.normalize(input, Normalizer.Form.NFD)
.replace(Regex("\\p{M}+"), "")
.lowercase(Locale.ROOT)
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
.replace(Regex("\\s+"), " ")Then searchKey.contains(normalizedQuery) or token all { it in key }. This
makes futbol match Fútbol, LALIGA match LaLiga, and ignores punctuation.
Also search tvgName and group if the user enables "search all fields".
UI behavior:
- Query is debounced ~150 ms in the ViewModel.
- Filtering runs on
Dispatchers.Default; the UI only collects the result. - Active tab filters first (Live/Movies/Series/All), then query, then group.
- Show "X de Y canales" so the user knows the filter worked.
- Empty state must say whether the playlist is empty, the tab has no entries, or
the search has no matches.
Performance budgets
- Parse tens of thousands of entries in under ~2 s on a mid-range phone; show progress if slower.
- Do not allocate per-line regexes. Compile attribute/search regexes once.
- Avoid
String.formatin the loop; use simple concatenation. - Keep
Channelimmutable and lean. tens of thousands × ~400 B is acceptable; do not also
keep the raw multi-megabyte document after parsing. - The raw playlist may stay in OkHttp's disk cache, not in RAM.
- List:
LazyColumn, stablekey = channel.id,contentType = sourceType.
Rows must not create players, fetch images synchronously, or holdremember
state per row. - Logos: Coil with a small memory/disk cache and a placeholder; do not block the
list on logo loading. If logos are slow, make them optional.
Diagnostics and errors
Use a sealed error type and map it to a short user sentence:
| Condition | User message (Spanish) | Detail |
|---|---|---|
| Bad URL | "La URL no es válida. Debe empezar por http:// o https://." | parse error |
| Timeout | "La lista tardó demasiado en responder. Comprueba tu conexión o la URL." | timeout ms |
| 401/403 | "El proveedor rechazó la lista (403). Comprueba usuario/contraseña o que la suscripción esté activa." | status |
| 404 | "La URL no existe (404). Revisa que sea la dirección completa de la lista." | status |
| 5xx | "El servidor del proveedor falló (5xx). Inténtalo más tarde." | status |
| HTML | "La URL devolvió una página web, no una lista M3U." | content-type |
| Empty | "La lista se descargó pero no contiene canales." | entries=0 |
| Partial | "Se cargaron N canales; M líneas no se pudieron leer." | warnings |
| Too large | "La lista es demasiado grande para cargarla en este dispositivo." | bytes |
Keep the technical detail available behind "Ver detalles" but never include the
raw URL. redactUrl() must remove userinfo and query values forusername/password/token/key/auth, and for Xtream paths log only host + id.
Tests and fixtures
Committed fixtures must use fake credentials and public streams:
samples/demo.m3u— three public HLS streams.bom-crlf.m3u— UTF-8 BOM + CRLF.attrs.m3u— quoted commas in names/logos, missing attributes.relative.m3u— relative channel URLs.latin1.m3u— ISO-8859-1 bytes withFútbol.empty.m3u— header only.html-error.txt— HTML instead of M3U.hls-media.m3u8— a media playlist, not a channel list.
Generate a 100k-entry fixture in the test at runtime; never commit a huge file.
Never copy the user's local/ playlist into tests.
Checklist
- Streaming parse, no whole-file regex.
- BOM/CRLF/Latin-1/relative URL handled.
- Comma-in-name attribute split is quote-aware.
- Classification by
/live/,/movie/,/series/+ fallback. - Search key precomputed; accents/case/punctuation insensitive.
- Counts and groups surfaced in the UI.
- Parse warnings are visible, not swallowed.
- No credential in logs, errors, or analytics.
- large-entry list does not ANR, OOM, or jank the list.