Skip to content

Commit

Permalink
Merge pull request #130 from Comcast/playlist-delta-updates
Browse files Browse the repository at this point in the history
Introduce EXT-X-SKIP support
  • Loading branch information
rmigneco authored Sep 4, 2024
2 parents b79049c + f535098 commit db05335
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -372,16 +372,44 @@ fileprivate struct HLSPlaylistStructureConstructor {
var currentSegmentDuration: CMTime = CMTime.invalid
var discontinuity = false
let tagDescriptor = self.tagDescriptor(forTags: tags)

// figure out our media sequence start (defaults to 1 if not specified)
let mediaSequenceTags = tags.filter{ $0.tagDescriptor == PantosTag.EXT_X_MEDIA_SEQUENCE }
if mediaSequenceTags.count > 0 {
assert(mediaSequenceTags.count == 1, "Unexpected to have more than one media sequence")
if let startMediaSequence: MediaSequence = mediaSequenceTags.first?.value(forValueIdentifier: PantosValue.sequence) {
currentMediaSequence = startMediaSequence

// collect media sequence and skip tag (if they exist) as they impact the initial media sequence value
var mediaSequenceTag: HLSTag?
var skipTag: HLSTag?
for tag in tags {
switch tag.tagDescriptor {
case PantosTag.EXT_X_MEDIA_SEQUENCE: mediaSequenceTag = tag
case PantosTag.EXT_X_SKIP: skipTag = tag
case PantosTag.Location:
// Both the EXT-X-MEDIA-SEQUNCE and the EXT-X-SKIP tag are expected to occur before any Media Segments.
//
// For EXT-X-MEDIA-SEQUNCE section 4.4.3.2 indicates:
// The EXT-X-MEDIA-SEQUENCE tag MUST appear before the first Media Segment in the Playlist.
//
// For EXT-X-SKIP section 4.4.5.2 indicates:
// A server produces a Playlist Delta Update (Section 6.2.5.1), by replacing tags earlier than the
// Skip Boundary with an EXT-X-SKIP tag. When replacing Media Segments, the EXT-X-SKIP tag replaces
// the segment URI lines and all Media Segment Tags tags that are applied to those segments.
//
// Exiting early at the first Location helps us avoid having to loop through the entire playlist when we
// know that the tags we're looking for MUST NOT exist.
break
default: continue
}
}


// figure out our media sequence start (defaults to 0 if not specified)
if let startMediaSequence: MediaSequence = mediaSequenceTag?.value(forValueIdentifier: PantosValue.sequence) {
currentMediaSequence = startMediaSequence
}

// account for any skip tag (since a delta update replaces all segments earlier than the skip boundary, the
// SKIPPED-SEGMENTS value will effectively update the current media sequence value of the first segment, so safe
// to do this here and not within the looping through media group tags below).
if let skippedSegments: Int = skipTag?.value(forValueIdentifier: PantosValue.skippedSegments) {
currentMediaSequence += skippedSegments
}

// find the "header" portion by finding the first ".mediaSegment" scoped tag
let mediaStartIndexOptional = tags.firstIndex(where: { $0.scope() == .mediaSegment })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public enum PantosTag: String {

// MARK: Variant playlist - Media metadata tags
case EXT_X_DATERANGE = "EXT-X-DATERANGE"
case EXT_X_SKIP = "EXT-X-SKIP"
}

extension PantosTag: HLSTagDescriptor, Equatable {
Expand Down Expand Up @@ -139,6 +140,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
case .EXT_X_TARGETDURATION:
fallthrough
case .EXT_X_DATERANGE:
fallthrough
case .EXT_X_SKIP:
return .wholePlaylist

case .EXT_X_BITRATE:
Expand Down Expand Up @@ -204,6 +207,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
case .EXT_X_KEY:
fallthrough
case .EXT_X_DATERANGE:
fallthrough
case .EXT_X_SKIP:
return .keyValue

case .Location:
Expand Down Expand Up @@ -278,6 +283,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
case .EXT_X_KEY:
fallthrough
case .EXT_X_DATERANGE:
fallthrough
case .EXT_X_SKIP:
return GenericDictionaryTagParser(tag: pantostag)

// No Data tags
Expand Down Expand Up @@ -342,6 +349,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
case .EXT_X_KEY:
fallthrough
case .EXT_X_DATERANGE:
fallthrough
case .EXT_X_SKIP:
return GenericDictionaryTagWriter()

// These tags cannot be modified and therefore these cases are invalid.
Expand Down Expand Up @@ -464,7 +473,17 @@ extension PantosTag: HLSTagDescriptor, Equatable {

case .EXT_X_DATERANGE:
return EXT_X_DATERANGETagValidator()


case .EXT_X_SKIP:
return GenericDictionaryTagValidator(tag: pantostag, dictionaryValueIdentifiers: [
HLSDictionaryTagValueIdentifierImpl(valueId: PantosValue.skippedSegments,
optional: false,
expectedType: Int.self),
HLSDictionaryTagValueIdentifierImpl(valueId: PantosValue.recentlyRemovedDateranges,
optional: true,
expectedType: String.self)
])

case .Location:
return nil

Expand Down Expand Up @@ -510,7 +529,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
PantosTag.EXT_X_START,
PantosTag.EXT_X_DISCONTINUITY,
PantosTag.EXT_X_BITRATE,
PantosTag.EXT_X_DATERANGE]
PantosTag.EXT_X_DATERANGE,
PantosTag.EXT_X_SKIP]

var dictionary = [UInt: [(descriptor: PantosTag, string: HLSStringRef)]]()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,22 @@ public enum PantosValue: String {
/// after the START-DATE of the range in question. This attribute is
/// OPTIONAL.
case endOnNext = "END-ON-NEXT"


/// Found in `.EXT_X_SKIP`.
///
/// The value is a decimal-integer specifying the number of Media
/// Segments replaced by the EXT-X-SKIP tag. This attribute is
/// REQUIRED.
case skippedSegments = "SKIPPED-SEGMENTS"

/// Found in `.EXT_X_SKIP`.
///
/// The value is a quoted-string consisting of a tab (0x9) delimited
/// list of EXT-X-DATERANGE IDs that have been removed from the
/// Playlist recently. See Section 6.2.5.1 for more information.
/// This attribute is REQUIRED if the Client requested an update that
/// skips EXT-X-DATERANGE tags. The quoted-string MAY be empty.
case recentlyRemovedDateranges = "RECENTLY-REMOVED-DATERANGES"
}

extension PantosValue: HLSTagValueIdentifier {
Expand Down
42 changes: 42 additions & 0 deletions mambaTests/HLSPlaylistStructureAndEditingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,26 @@ class HLSPlaylistStructureAndEditingTests: XCTestCase {
XCTAssert(playlist.footer?.range.count == 1, "Should have a footer")
XCTAssert(playlist.mediaSpans.count == 2, "Should have 2 spans")
}

func testDeltaUpdateCorrectlyCalculatesMediaSequencesInTagGroups() {
let playlist = parsePlaylist(inString: sampleDeltaUpdatePlaylist)

XCTAssertEqual(playlist.header?.range.count, 5, "Should have a header including 'server-control' and 'skip'")
XCTAssertEqual(playlist.mediaSegmentGroups.count, 6, "Should have 6 remaining groups")
for i in 0..<6 {
guard playlist.mediaSegmentGroups.indices.contains(i) else {
return XCTFail("Should have media segment group at index \(i)")
}
let group = playlist.mediaSegmentGroups[i]
XCTAssertEqual(
group.mediaSequence,
i + 5,
"Should have media sequence value equal to index (\(i)) + initial media sequence (1) + skipped (4)"
)
}
XCTAssertNil(playlist.footer, "Should have no footer")
XCTAssertEqual(playlist.mediaSpans.count, 0, "Should have no spans (no key tags)")
}
}


Expand Down Expand Up @@ -937,3 +957,25 @@ let sample4SegmentPlaylist =
"#EXTINF:2.002,\n" +
"http://not.a.server.nowhere/segment4.ts\n" +
"#EXT-X-ENDLIST\n"

let sampleDeltaUpdatePlaylist =
"""
#EXTM3U
#EXT-X-VERSION:9
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=12
#EXT-X-TARGETDURATION:2
#EXT-X-SKIP:SKIPPED-SEGMENTS=4
#EXTINF:2.002,
http://not.a.server.nowhere/segment5.ts
#EXTINF:2.002,
http://not.a.server.nowhere/segment6.ts
#EXTINF:2.002,
http://not.a.server.nowhere/segment7.ts
#EXTINF:2.002,
http://not.a.server.nowhere/segment8.ts
#EXTINF:2.002,
http://not.a.server.nowhere/segment9.ts
#EXTINF:2.002,
http://not.a.server.nowhere/segment10.ts
"""
3 changes: 3 additions & 0 deletions mambaTests/PantosTagTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class PantosTagTests: XCTestCase {
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_ENDLIST)
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_BITRATE)
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_DATERANGE)
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_SKIP)

runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_INDEPENDENT_SEGMENTS)
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_START)
Expand Down Expand Up @@ -101,6 +102,8 @@ class PantosTagTests: XCTestCase {
case .EXTINF:
fallthrough
case .EXT_X_DATERANGE:
fallthrough
case .EXT_X_SKIP:
let stringRef = HLSStringRef(string: "#\(descriptor.toString())")
guard let newDescriptor = PantosTag.constructDescriptor(fromStringRef: stringRef) else {
XCTFail("PantosTag \(descriptor.toString()) is missing from stringRefLookup table.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -797,4 +797,46 @@ class GenericDictionaryTagValidatorTests: XCTestCase {
"Expected EXT-X-DATERANGE validation issue (\(expectedValidationIssue.description)) had unexpected severity (\(matchingIssue.severity))")
}
}

/*
A server produces a Playlist Delta Update (Section 6.2.5.1), by
replacing tags earlier than the Skip Boundary with an EXT-X-SKIP tag.

When replacing Media Segments, the EXT-X-SKIP tag replaces the
segment URI lines and all Media Segment Tags tags that are applied to
those segments. This tag MUST NOT appear more than once in a
Playlist.

Its format is:

#EXT-X-SKIP:<attribute-list>

The following attributes are defined:

SKIPPED-SEGMENTS

The value is a decimal-integer specifying the number of Media
Segments replaced by the EXT-X-SKIP tag. This attribute is
REQUIRED.

RECENTLY-REMOVED-DATERANGES

The value is a quoted-string consisting of a tab (0x9) delimited
list of EXT-X-DATERANGE IDs that have been removed from the
Playlist recently. See Section 6.2.5.1 for more information.
This attribute is REQUIRED if the Client requested an update that
skips EXT-X-DATERANGE tags. The quoted-string MAY be empty.
*/
func test_EXT_X_SKIP() {
let tagData = "SKIPPED-SEGMENTS=10,RECENTLY-REMOVED-DATERANGES=\"\""
let optional: [PantosValue] = [.recentlyRemovedDateranges]
let mandatory: [PantosValue] = [.skippedSegments]
let badValues: [PantosValue] = [.skippedSegments]

validate(tag: PantosTag.EXT_X_SKIP,
tagData: tagData,
optional: optional,
mandatory: mandatory,
badValues: badValues)
}
}

0 comments on commit db05335

Please sign in to comment.