blob: 0d2f5b35bfdffb5b95fbb3930b79be0aa282aa1e [file] [log] [blame]
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "media/formats/hls/media_playlist.h"
#include <vector>
#include "base/callback_list.h"
#include "base/location.h"
#include "media/formats/hls/items.h"
#include "media/formats/hls/media_segment.h"
#include "media/formats/hls/source_string.h"
#include "media/formats/hls/tags.h"
#include "media/formats/hls/types.h"
#include "testing/gtest/include/gtest/gtest.h"
namespace media::hls {
namespace {
class TestBuilder {
public:
void SetUri(GURL uri) { uri_ = std::move(uri); }
// Appends text to the playlist, without a trailing newline.
void Append(base::StringPiece text) {
source_.append(text.data(), text.size());
}
// Appends a new line to the playlist.
void AppendLine(base::StringPiece line) {
Append(line);
Append("\n");
}
// Adds a new expectation for the playlist, which will be checked during
// `ExpectOk`.
template <typename Fn, typename Arg>
void ExpectPlaylist(Fn fn,
Arg arg,
base::Location location = base::Location::Current()) {
playlist_expectations_.emplace_back(base::BindRepeating(
std::move(fn), std::move(arg), std::move(location)));
}
// Increments the number of segments that are expected to be contained in the
// playlist.
void ExpectAdditionalSegment() { segment_expectations_.push_back({}); }
// Adds a new expectation for the latest segment in the playlist, which will
// be checked during `ExpectOk`.
template <typename Fn, typename Arg>
void ExpectSegment(Fn fn,
Arg arg,
base::Location location = base::Location::Current()) {
segment_expectations_.back().expectations.emplace_back(base::BindRepeating(
std::move(fn), std::move(arg), std::move(location)));
}
// Attempts to parse the playlist as-is, checking for the given
// error code.
void ExpectError(
ParseStatusCode code,
const base::Location& from = base::Location::Current()) const {
auto result = MediaPlaylist::Parse(source_, uri_);
ASSERT_TRUE(result.has_error()) << from.ToString();
EXPECT_EQ(std::move(result).error().code(), code) << from.ToString();
}
// Attempts to parse the playlist as-is, checking all playlist and segment
// expectations.
void ExpectOk(const base::Location& from = base::Location::Current()) const {
auto result = MediaPlaylist::Parse(source_, uri_);
ASSERT_TRUE(result.has_value())
<< "Error: "
<< ParseStatusCodeToString(std::move(result).error().code()) << "\n"
<< from.ToString();
auto playlist = std::move(result).value();
for (const auto& expectation : playlist_expectations_) {
expectation.Run(playlist);
}
ASSERT_EQ(segment_expectations_.size(), playlist.GetSegments().size())
<< from.ToString();
for (size_t i = 0; i < segment_expectations_.size(); ++i) {
const auto& segment = playlist.GetSegments().at(i);
const auto& expectations = segment_expectations_.at(i);
for (const auto& expectation : expectations.expectations) {
expectation.Run(segment);
}
}
}
private:
struct SegmentExpectations {
std::vector<base::RepeatingCallback<void(const MediaSegment&)>>
expectations;
};
std::vector<SegmentExpectations> segment_expectations_;
std::vector<base::RepeatingCallback<void(const MediaPlaylist&)>>
playlist_expectations_;
GURL uri_ = GURL("http://localhost/playlist.m3u8");
std::string source_;
};
void HasVersion(types::DecimalInteger version,
const base::Location& from,
const MediaPlaylist& playlist) {
EXPECT_EQ(playlist.GetVersion(), version) << from.ToString();
}
void HasType(absl::optional<PlaylistType> type,
const base::Location& from,
const MediaPlaylist& playlist) {
EXPECT_EQ(playlist.GetPlaylistType(), type) << from.ToString();
}
void HasDuration(types::DecimalFloatingPoint duration,
const base::Location& from,
const MediaSegment& segment) {
EXPECT_DOUBLE_EQ(segment.GetDuration(), duration) << from.ToString();
}
void HasUri(GURL uri, const base::Location& from, const MediaSegment& segment) {
EXPECT_EQ(segment.GetUri(), uri) << from.ToString();
}
void HasDiscontinuity(bool value,
const base::Location& from,
const MediaSegment& segment) {
EXPECT_EQ(segment.HasDiscontinuity(), value) << from.ToString();
}
void IsGap(bool value,
const base::Location& from,
const MediaSegment& segment) {
EXPECT_EQ(segment.IsGap(), value) << from.ToString();
}
} // namespace
TEST(HlsFormatParserTest, ParseMediaPlaylist_BadLineEndings) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
{
// Double carriage-return is not allowed
auto fork = builder;
fork.Append("\r\r\n");
fork.ExpectError(ParseStatusCode::kInvalidEOL);
}
{
// Carriage-return not followed by a newline is not allowed
auto fork = builder;
fork.Append("#EXT-X-VERSION:5\r");
fork.ExpectError(ParseStatusCode::kInvalidEOL);
}
builder.Append("\r\n");
builder.ExpectOk();
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_MissingM3u) {
// #EXTM3U must be the very first line
TestBuilder builder;
builder.AppendLine("");
builder.AppendLine("#EXTM3U");
builder.ExpectError(ParseStatusCode::kPlaylistMissingM3uTag);
builder = TestBuilder();
builder.AppendLine("#EXT-X-VERSION:5");
builder.AppendLine("#EXTM3U");
builder.ExpectError(ParseStatusCode::kPlaylistMissingM3uTag);
// Test with invalid line ending
builder = TestBuilder();
builder.Append("#EXTM3U");
builder.ExpectError(ParseStatusCode::kPlaylistMissingM3uTag);
// Test with invalid format
builder = TestBuilder();
builder.AppendLine("#EXTM3U:");
builder.ExpectError(ParseStatusCode::kPlaylistMissingM3uTag);
builder = TestBuilder();
builder.AppendLine("#EXTM3U:1");
builder.ExpectError(ParseStatusCode::kPlaylistMissingM3uTag);
// Extra M3U tag is OK
builder = TestBuilder();
builder.AppendLine("#EXTM3U");
builder.AppendLine("#EXTM3U");
builder.ExpectOk();
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_UnknownTag) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
// Unrecognized tags should not result in an error
builder.AppendLine("#EXT-UNKNOWN-TAG");
builder.ExpectOk();
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_XDiscontinuityTag) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
// Default discontinuity state is false
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("video.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(HasDiscontinuity, false);
builder.AppendLine("#EXT-X-DISCONTINUITY");
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("video.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(HasDiscontinuity, true);
// The discontinuity tag does not apply to subsequent segments
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("video.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(HasDiscontinuity, false);
// The discontinuity tag may only appear once per segment
{
auto fork = builder;
fork.AppendLine("#EXT-X-DISCONTINUITY");
fork.AppendLine("#EXT-X-DISCONTINUITY");
fork.AppendLine("#EXTINF:9.9,\t");
fork.AppendLine("video.ts");
fork.ExpectAdditionalSegment();
fork.ExpectSegment(HasDiscontinuity, true);
fork.ExpectError(ParseStatusCode::kPlaylistHasDuplicateTags);
}
builder.ExpectOk();
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_XGapTag) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
// Default gap state is false
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("video.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(IsGap, false);
builder.AppendLine("#EXT-X-GAP");
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("video.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(IsGap, true);
// The gap tag does not apply to subsequent segments
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("video.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(IsGap, false);
// The gap tag may only appear once per segment
{
auto fork = builder;
fork.AppendLine("#EXT-X-GAP");
fork.AppendLine("#EXT-X-GAP");
fork.AppendLine("#EXTINF:9.9,\t");
fork.AppendLine("video.ts");
fork.ExpectAdditionalSegment();
fork.ExpectSegment(IsGap, true);
fork.ExpectError(ParseStatusCode::kPlaylistHasDuplicateTags);
}
builder.ExpectOk();
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_VersionChecks) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
{
// Default version is 1
auto fork = builder;
fork.ExpectPlaylist(HasVersion, 1);
fork.ExpectOk();
}
{
// "-1" is not a valid decimal-integer
auto fork = builder;
fork.AppendLine("#EXT-X-VERSION:-1");
fork.ExpectError(ParseStatusCode::kMalformedTag);
}
{
// "0" is not a valid version
auto fork = builder;
fork.AppendLine("#EXT-X-VERSION:0");
fork.ExpectError(ParseStatusCode::kInvalidPlaylistVersion);
}
for (int i = 1; i <= 10; ++i) {
auto fork = builder;
fork.AppendLine("#EXT-X-VERSION:" + base::NumberToString(i));
fork.ExpectPlaylist(HasVersion, i);
fork.ExpectOk();
}
for (int i : {11, 12, 100, 999}) {
// Versions 11+ are not supported by this parser
auto fork = builder;
fork.AppendLine("#EXT-X-VERSION:" + base::NumberToString(i));
fork.ExpectError(ParseStatusCode::kPlaylistHasUnsupportedVersion);
}
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_Segments) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
builder.AppendLine("#EXT-X-VERSION:5");
builder.ExpectPlaylist(HasVersion, 5);
builder.AppendLine("#EXTINF:9.2,\t");
builder.AppendLine("video.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(HasDiscontinuity, false);
builder.ExpectSegment(HasDuration, 9.2);
builder.ExpectSegment(HasUri, GURL("http://localhost/video.ts"));
builder.ExpectSegment(IsGap, false);
// Segments without #EXTINF tags are not allowed
{
auto fork = builder;
fork.AppendLine("foobar.ts");
fork.ExpectError(ParseStatusCode::kMediaSegmentMissingInfTag);
}
builder.AppendLine("#EXTINF:9.3,foo");
builder.AppendLine("#EXT-X-DISCONTINUITY");
builder.AppendLine("foo.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(HasDiscontinuity, true);
builder.ExpectSegment(HasDuration, 9.3);
builder.ExpectSegment(IsGap, false);
builder.ExpectSegment(HasUri, GURL("http://localhost/foo.ts"));
builder.AppendLine("#EXTINF:9.2,bar");
builder.AppendLine("http://foo/bar.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(HasDiscontinuity, false);
builder.ExpectSegment(HasDuration, 9.2);
builder.ExpectSegment(IsGap, false);
builder.ExpectSegment(HasUri, GURL("http://foo/bar.ts"));
builder.ExpectOk();
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_Define) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
builder.AppendLine("#EXT-X-VERSION:8");
builder.ExpectPlaylist(HasVersion, 8);
builder.AppendLine(R"(#EXT-X-DEFINE:NAME="ROOT",VALUE="http://video.com")");
builder.AppendLine(R"(#EXT-X-DEFINE:NAME="MOVIE",VALUE="some_video/low")");
// Valid variable references within URI items should be substituted
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("{$ROOT}/{$MOVIE}/fileSegment0.ts");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(
HasUri, GURL("http://video.com/some_video/low/fileSegment0.ts"));
{
// Invalid variable references within URI lines should result in an error
auto fork = builder;
fork.AppendLine("#EXTINF:9.9,\t");
fork.AppendLine("{$root}/{$movie}/fileSegment0.ts");
fork.ExpectError(ParseStatusCode::kVariableUndefined);
}
// Variable references outside of valid substitution points should not be
// substituted
{
auto fork = builder;
fork.AppendLine(R"(#EXT-X-DEFINE:NAME="LENGTH",VALUE="9.9")");
fork.AppendLine("#EXTINF:{$LENGTH},\t");
fork.AppendLine("http://foo/bar");
fork.ExpectError(ParseStatusCode::kMalformedTag);
}
// Redefinition is not allowed
{
auto fork = builder;
fork.AppendLine(
R"(#EXT-X-DEFINE:NAME="ROOT",VALUE="https://www.google.com")");
fork.ExpectError(ParseStatusCode::kVariableDefinedMultipleTimes);
}
// Importing in a parentless playlist is not allowed
{
auto fork = builder;
fork.AppendLine(R"(#EXT-X-DEFINE:IMPORT="IMPORTED")");
fork.ExpectError(ParseStatusCode::kImportedVariableInParentlessPlaylist);
}
// Variables may not be substituted recursively
builder.AppendLine(R"(#EXT-X-DEFINE:NAME="BAR",VALUE="BAZ")");
builder.AppendLine(R"(#EXT-X-DEFINE:NAME="FOO",VALUE="{$BAR}")");
builder.AppendLine("#EXTINF:9.9,\t");
builder.AppendLine("http://{$FOO}.com/video");
builder.ExpectAdditionalSegment();
builder.ExpectSegment(HasUri, GURL("http://{$BAR}.com/video"));
builder.ExpectOk();
}
TEST(HlsFormatParserTest, ParseMediaPlaylist_PlaylistType) {
TestBuilder builder;
builder.AppendLine("#EXTM3U");
// Without the EXT-X-PLAYLIST-TYPE tag, the playlist has no type.
{
auto fork = builder;
fork.ExpectPlaylist(HasType, absl::nullopt);
fork.ExpectOk();
}
{
auto fork = builder;
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
fork.ExpectPlaylist(HasType, PlaylistType::kVOD);
fork.ExpectOk();
}
{
auto fork = builder;
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:EVENT");
fork.ExpectPlaylist(HasType, PlaylistType::kEvent);
fork.ExpectOk();
}
// This tag may not be specified twice
{
auto fork = builder;
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:EVENT");
fork.ExpectError(ParseStatusCode::kPlaylistHasDuplicateTags);
}
{
auto fork = builder;
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
fork.ExpectError(ParseStatusCode::kPlaylistHasDuplicateTags);
}
// Unknown or invalid playlist types should trigger an error
{
auto fork = builder;
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:FOOBAR");
fork.ExpectError(ParseStatusCode::kUnknownPlaylistType);
}
{
auto fork = builder;
fork.AppendLine("#EXT-X-PLAYLIST-TYPE:");
fork.ExpectError(ParseStatusCode::kMalformedTag);
}
{
auto fork = builder;
fork.AppendLine("#EXT-X-PLAYLIST-TYPE");
fork.ExpectError(ParseStatusCode::kMalformedTag);
}
}
} // namespace media::hls