Skip to content

Commit

Permalink
IPTV HLS Support AES-128 encrypted channels
Browse files Browse the repository at this point in the history
Support decoding of AES-128 encrypted channels.
This was already implemented a long time ago but needed
some fixing to make it work with today's streams.
Check existence of http(s) URLs by reading the first part
and do not start recording when the URL does not exist.
This prevents waiting when playing IPTV channels with Live TV.

Refs #936
  • Loading branch information
kmdewaal committed Jan 25, 2025
1 parent 6e43885 commit 30c76bc
Show file tree
Hide file tree
Showing 11 changed files with 348 additions and 171 deletions.
162 changes: 133 additions & 29 deletions mythtv/libs/libmythtv/HLS/m3u.cpp
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
#include <QRegularExpression>
#include <QStringList>
#include <QUrl>

#include "libmythbase/mythdate.h"
#include "libmythbase/mythlogging.h"
#include "HLS/m3u.h"

namespace M3U
{
static const QRegularExpression kQuotes{"^\"|\"$"};

QString DecodedURI(const QString& uri)
{
QByteArray ba = uri.toLatin1();
Expand Down Expand Up @@ -116,10 +120,15 @@ namespace M3U
return true;
}

// EXT-X-STREAM-INF
//
bool ParseStreamInformation(const QString& line,
const QString& url,
const QString& loc,
int& id, uint64_t& bandwidth)
int& id,
uint64_t& bandwidth,
QString& audio,
QString& video)
{
LOG(VB_RECORD, LOG_INFO, loc +
QString("Parsing stream from %1").arg(url));
Expand Down Expand Up @@ -161,13 +170,65 @@ namespace M3U
return false;
}

// AUDIO
//
// The value is a quoted-string. It MUST match the value of the
// GROUP-ID attribute of an EXT-X-MEDIA tag elsewhere in the Master
// Playlist whose TYPE attribute is AUDIO. It indicates the set of
// audio Renditions that SHOULD be used when playing the
// presentation. See Section 4.3.4.2.1.
//
// The AUDIO attribute is OPTIONAL.
audio = ParseAttributes(line, "AUDIO");
if (!audio.isEmpty())
{
audio.replace(M3U::kQuotes, "");
LOG(VB_RECORD, LOG_INFO, loc +
QString("#EXT-X-STREAM-INF: attribute AUDIO=%1").arg(audio));
}

// The VIDEO attribute is OPTIONAL.
video = ParseAttributes(line, "VIDEO");
if (!video.isEmpty())
{
video.replace(M3U::kQuotes, "");
LOG(VB_RECORD, LOG_INFO, loc +
QString("#EXT-X-STREAM-INF: attribute VIDEO=%1").arg(video));
}


LOG(VB_RECORD, LOG_INFO, loc +
QString("bandwidth adaptation detected (program-id=%1, bandwidth=%2")
QString("bandwidth adaptation detected (program-id=%1, bandwidth=%2)")
.arg(id).arg(bandwidth));

return true;
}

// EXT-X-MEDIA
//
bool ParseMedia(const QString& line,
const QString& loc,
QString& media_type,
QString& group_id,
QString& uri,
QString& name)
{
LOG(VB_RECORD, LOG_INFO, loc + QString("Parsing EXT-X-MEDIA line"));

media_type = ParseAttributes(line, "TYPE");
group_id = ParseAttributes(line, "GROUP-ID");
uri = ParseAttributes(line, "URI");
name = ParseAttributes(line, "NAME");

// Remove string quotes
group_id.replace(M3U::kQuotes, "");
uri.replace(M3U::kQuotes, "");
name.replace(M3U::kQuotes, "");

return true;
}


bool ParseTargetDuration(const QString& line, const QString& loc,
int& duration)
{
Expand All @@ -176,8 +237,6 @@ namespace M3U
*
* where s is an integer indicating the target duration in seconds.
*/
duration = -1;

if (!ParseDecimalValue(line, duration))
{
LOG(VB_RECORD, LOG_ERR, loc + "expected #EXT-X-TARGETDURATION:<s>");
Expand All @@ -188,17 +247,22 @@ namespace M3U
}

bool ParseSegmentInformation(int version, const QString& line,
uint& duration, QString& title,
int& duration, QString& title,
const QString& loc)
{
/*
* #EXTINF:<duration>,<title>
*
* "duration" is an integer that specifies the duration of the media
* file in seconds. Durations SHOULD be rounded to the nearest integer.
* The remainder of the line following the comma is the title of the
* media file, which is an optional human-readable informative title of
* the media segment
* where duration is a decimal-floating-point or decimal-integer number
* (as described in Section 4.2) that specifies the duration of the
* Media Segment in seconds. Durations SHOULD be decimal-floating-
* point, with enough accuracy to avoid perceptible error when segment
* durations are accumulated. However, if the compatibility version
* number is less than 3, durations MUST be integers. Durations that
* are reported as integers SHOULD be rounded to the nearest integer.
* The remainder of the line following the comma is an optional human-
* readable informative title of the Media Segment expressed as UTF-8
* text.
*/
int p = line.indexOf(QLatin1String(":"));
if (p < 0)
Expand All @@ -220,36 +284,35 @@ namespace M3U
return false;
}

// Duration in ms
bool ok = false;
const QString& val = list[0];

if (version < 3)
{
bool ok = false;
duration = val.toInt(&ok);
if (!ok)
int duration_seconds = val.toInt(&ok);
if (ok)
{
duration = duration_seconds * 1000;
}
else
{
duration = -1;
LOG(VB_RECORD, LOG_ERR, loc +
QString("ParseSegmentInformation: invalid duration in '%1'")
.arg(line));
return false;
}
}
else
{
bool ok = false;
double d = val.toDouble(&ok);
if (!ok)
{
duration = -1;
LOG(VB_RECORD, LOG_ERR, loc +
QString("ParseSegmentInformation: invalid duration in '%1'")
.arg(line));
return false;
}
if ((d) - ((int)d) >= 0.5)
duration = ((int)d) + 1;
else
duration = ((int)d);
duration = static_cast<int>(d * 1000);
}

if (list.size() >= 2)
Expand All @@ -276,7 +339,6 @@ namespace M3U
if (!ParseDecimalValue(line, sequence_num))
{
LOG(VB_RECORD, LOG_ERR, loc + "expected #EXT-X-MEDIA-SEQUENCE:<s>");
sequence_num = 0;
return false;
}

Expand Down Expand Up @@ -307,16 +369,16 @@ namespace M3U
if (attr.startsWith(QLatin1String("NONE")))
{
QString uri = ParseAttributes(line, "URI");
if (!uri.isNull())
if (!uri.isEmpty())
{
LOG(VB_RECORD, LOG_ERR, loc + "#EXT-X-KEY: URI not expected");
return false;
}
/* IV is only supported in version 2 and above */
if (version >= 2)
{
iv = ParseAttributes(line, "IV");
if (!iv.isNull())
QString parsed_iv = ParseAttributes(line, "IV");
if (!parsed_iv.isEmpty())
{
LOG(VB_RECORD, LOG_ERR, loc + "#EXT-X-KEY: IV not expected");
return false;
Expand Down Expand Up @@ -344,6 +406,9 @@ namespace M3U
/* Url is between quotes, remove them */
path = DecodedURI(uri.remove(QChar(QLatin1Char('"'))));
iv = ParseAttributes(line, "IV");

LOG(VB_RECORD, LOG_DEBUG, QString("M3U::ParseKey #EXT-X-KEY: %1").arg(line));
LOG(VB_RECORD, LOG_DEBUG, QString("M3U::ParseKey path:%1 IV:%2").arg(path).arg(iv));
}
else if (attr.startsWith(QLatin1String("SAMPLE-AES")))
{
Expand Down Expand Up @@ -371,15 +436,54 @@ namespace M3U
return true;
}

bool ParseMap(const QString &line,
const QString &loc,
QString &uri)
{
/*
* #EXT-X-MAP:<attribute-list>
*
* The EXT-X-MAP tag specifies how to obtain the Media Initialization
* Section (Section 3) required to parse the applicable Media Segments.
* It applies to every Media Segment that appears after it in the
* Playlist until the next EXT-X-MAP tag or until the end of the
* Playlist.
*
* The following attributes are defined:
*
* URI
* The value is a quoted-string containing a URI that identifies a
* resource that contains the Media Initialization Section. This
* attribute is REQUIRED.
*/
uri = ParseAttributes(line, "URI");
if (uri.isEmpty())
{
LOG(VB_RECORD, LOG_ERR, loc +
QString("Attribute URI not present in: #EXT-X-MAP %1")
.arg(line));
return false;
}
return true;
}

bool ParseProgramDateTime(const QString& line, const QString& loc,
QDateTime &/*date*/)
QDateTime &dt)
{
/*
* #EXT-X-PROGRAM-DATE-TIME:<YYYY-MM-DDThh:mm:ssZ>
*/
LOG(VB_RECORD, LOG_DEBUG, loc +
QString("tag not supported: #EXT-X-PROGRAM-DATE-TIME %1")
.arg(line));
int p = line.indexOf(QLatin1String(":"));
if (p < 0)
{
LOG(VB_RECORD, LOG_ERR, loc +
QString("ParseProgramDateTime: Missing ':' in '%1'")
.arg(line));
return false;
}

QString dt_string = line.mid(p+1);
dt = MythDate::fromString(dt_string);
return true;
}

Expand Down
16 changes: 14 additions & 2 deletions mythtv/libs/libmythtv/HLS/m3u.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,28 @@ namespace M3U
bool ParseStreamInformation(const QString& line,
const QString& url,
const QString& loc,
int& id, uint64_t& bandwidth);
int& id,
uint64_t& bandwidth,
QString& audio,
QString& video);
bool ParseMedia(const QString& line,
const QString& loc,
QString& media_type,
QString& group_id,
QString& uri,
QString& name);
bool ParseTargetDuration(const QString& line, const QString& loc,
int& duration);
bool ParseSegmentInformation(int version, const QString& line,
uint& duration, QString& title,
int& duration, QString& title,
const QString& loc);
bool ParseMediaSequence(int64_t & sequence_num, const QString& line,
const QString& loc);
bool ParseKey(int version, const QString& line, bool& aesmsg,
const QString& loc, QString &path, QString &iv);
bool ParseMap(const QString &line,
const QString &loc,
QString &uri);
bool ParseProgramDateTime(const QString& line, const QString& loc,
QDateTime &date);
bool ParseAllowCache(const QString& line, const QString& loc,
Expand Down
41 changes: 27 additions & 14 deletions mythtv/libs/libmythtv/iptvtuningdata.h
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ class MTV_PUBLIC IPTVTuningData
return (m_protocol == http_ts);
}

// An http(s) URL is invalid if it cannot be downloaded
void GuessProtocol(void)
{
if (!m_dataUrl.isValid())
Expand All @@ -215,10 +216,19 @@ class MTV_PUBLIC IPTVTuningData
m_protocol = IPTVTuningData::rtp;
else if (m_dataUrl.scheme() == "rtsp")
m_protocol = IPTVTuningData::rtsp;
else if (((m_dataUrl.scheme() == "http") || (m_dataUrl.scheme() == "https")) && IsHLSPlaylist())
m_protocol = IPTVTuningData::http_hls;
else if ((m_dataUrl.scheme() == "http") || (m_dataUrl.scheme() == "https"))
m_protocol = IPTVTuningData::http_ts;
{
QByteArray buffer;
if (CanReadHTTP(buffer))
{
if (IsHLSPlaylist(buffer))
m_protocol = IPTVTuningData::http_hls;
else
m_protocol = IPTVTuningData::http_ts;
}
else
m_protocol = IPTVTuningData::inValid;
}
else
m_protocol = IPTVTuningData::inValid;
}
Expand All @@ -229,25 +239,28 @@ class MTV_PUBLIC IPTVTuningData
}

protected:
bool IsHLSPlaylist(void) const
{
if (QCoreApplication::instance() == nullptr)
{
LOG(VB_GENERAL, LOG_ERR, QString("IsHLSPlaylist - No QCoreApplication!!"));
return false;
}

MythSingleDownload downloader;
// Read first part of the http(s) URL.
// This is done to test if we can download from this URL
// and 2000 bytes is enough to determine in IsHLSPlaylist
// if the file is an HLS playlist or not.
//
bool CanReadHTTP(QByteArray &buffer) const
{
QString url = m_dataUrl.toString();
QByteArray buffer;
downloader.DownloadURL(url, &buffer, 5s, 0, 10000);
MythSingleDownload downloader;
downloader.DownloadURL(url, &buffer, 5s, 0, 2000);
if (buffer.isEmpty())
{
LOG(VB_GENERAL, LOG_ERR, QString("IsHLSPlaylist - Open Failed:%1 url:%2")
LOG(VB_GENERAL, LOG_ERR, QString("CanReadHTTP - Failed, error:%1 url:%2")
.arg(downloader.ErrorString(), url));
return false;
}
return true;
}

bool IsHLSPlaylist(QByteArray &buffer) const
{
QTextStream text(&buffer);
#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
text.setCodec("UTF-8");
Expand Down
2 changes: 1 addition & 1 deletion mythtv/libs/libmythtv/recorders/HLS/HLSPlaylistWorker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ void HLSPlaylistWorker::run(void)
RunProlog();

auto *downloader = new MythSingleDownload;

m_wokenup = true; // Otherwise always false and then we start with 1 second delay
while (!m_cancel)
{
m_lock.lock();
Expand Down
Loading

3 comments on commit 30c76bc

@jhoyt4
Copy link
Contributor

@jhoyt4 jhoyt4 commented on 30c76bc Jan 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kmdewaal - it appears this commit has broken the configure tests in someway. I'm only noticing as I've been working on the github workflow and things that passed before I rebased to include this commit are now failing.

@kmdewaal
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jhoyt4 part of this commit did break the unit test TestIPTVRecorder. I assume that is what causes the failure that you have observed but I am not completely certain of that. I have now restored the old behavior so that TestIPTVRecorder does pass again, at least with Qt5. If this does not fix the issues that you observe please report again.

@jhoyt4
Copy link
Contributor

@jhoyt4 jhoyt4 commented on 30c76bc Jan 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kmdewaal - Thank you for the response. It looks like my latest run with your update has resolved the CI failures. My apologies for the false alarm.

Please sign in to comment.