Skip to content

Commit db05335

Browse files
authored
Merge pull request #130 from Comcast/playlist-delta-updates
Introduce EXT-X-SKIP support
2 parents b79049c + f535098 commit db05335

File tree

6 files changed

+161
-11
lines changed

6 files changed

+161
-11
lines changed

mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift

+36-8
Original file line numberDiff line numberDiff line change
@@ -372,16 +372,44 @@ fileprivate struct HLSPlaylistStructureConstructor {
372372
var currentSegmentDuration: CMTime = CMTime.invalid
373373
var discontinuity = false
374374
let tagDescriptor = self.tagDescriptor(forTags: tags)
375-
376-
// figure out our media sequence start (defaults to 1 if not specified)
377-
let mediaSequenceTags = tags.filter{ $0.tagDescriptor == PantosTag.EXT_X_MEDIA_SEQUENCE }
378-
if mediaSequenceTags.count > 0 {
379-
assert(mediaSequenceTags.count == 1, "Unexpected to have more than one media sequence")
380-
if let startMediaSequence: MediaSequence = mediaSequenceTags.first?.value(forValueIdentifier: PantosValue.sequence) {
381-
currentMediaSequence = startMediaSequence
375+
376+
// collect media sequence and skip tag (if they exist) as they impact the initial media sequence value
377+
var mediaSequenceTag: HLSTag?
378+
var skipTag: HLSTag?
379+
for tag in tags {
380+
switch tag.tagDescriptor {
381+
case PantosTag.EXT_X_MEDIA_SEQUENCE: mediaSequenceTag = tag
382+
case PantosTag.EXT_X_SKIP: skipTag = tag
383+
case PantosTag.Location:
384+
// Both the EXT-X-MEDIA-SEQUNCE and the EXT-X-SKIP tag are expected to occur before any Media Segments.
385+
//
386+
// For EXT-X-MEDIA-SEQUNCE section 4.4.3.2 indicates:
387+
// The EXT-X-MEDIA-SEQUENCE tag MUST appear before the first Media Segment in the Playlist.
388+
//
389+
// For EXT-X-SKIP section 4.4.5.2 indicates:
390+
// A server produces a Playlist Delta Update (Section 6.2.5.1), by replacing tags earlier than the
391+
// Skip Boundary with an EXT-X-SKIP tag. When replacing Media Segments, the EXT-X-SKIP tag replaces
392+
// the segment URI lines and all Media Segment Tags tags that are applied to those segments.
393+
//
394+
// Exiting early at the first Location helps us avoid having to loop through the entire playlist when we
395+
// know that the tags we're looking for MUST NOT exist.
396+
break
397+
default: continue
382398
}
383399
}
384-
400+
401+
// figure out our media sequence start (defaults to 0 if not specified)
402+
if let startMediaSequence: MediaSequence = mediaSequenceTag?.value(forValueIdentifier: PantosValue.sequence) {
403+
currentMediaSequence = startMediaSequence
404+
}
405+
406+
// account for any skip tag (since a delta update replaces all segments earlier than the skip boundary, the
407+
// SKIPPED-SEGMENTS value will effectively update the current media sequence value of the first segment, so safe
408+
// to do this here and not within the looping through media group tags below).
409+
if let skippedSegments: Int = skipTag?.value(forValueIdentifier: PantosValue.skippedSegments) {
410+
currentMediaSequence += skippedSegments
411+
}
412+
385413
// find the "header" portion by finding the first ".mediaSegment" scoped tag
386414
let mediaStartIndexOptional = tags.firstIndex(where: { $0.scope() == .mediaSegment })
387415

mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosTag.swift

+22-2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public enum PantosTag: String {
7777

7878
// MARK: Variant playlist - Media metadata tags
7979
case EXT_X_DATERANGE = "EXT-X-DATERANGE"
80+
case EXT_X_SKIP = "EXT-X-SKIP"
8081
}
8182

8283
extension PantosTag: HLSTagDescriptor, Equatable {
@@ -139,6 +140,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
139140
case .EXT_X_TARGETDURATION:
140141
fallthrough
141142
case .EXT_X_DATERANGE:
143+
fallthrough
144+
case .EXT_X_SKIP:
142145
return .wholePlaylist
143146

144147
case .EXT_X_BITRATE:
@@ -204,6 +207,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
204207
case .EXT_X_KEY:
205208
fallthrough
206209
case .EXT_X_DATERANGE:
210+
fallthrough
211+
case .EXT_X_SKIP:
207212
return .keyValue
208213

209214
case .Location:
@@ -278,6 +283,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
278283
case .EXT_X_KEY:
279284
fallthrough
280285
case .EXT_X_DATERANGE:
286+
fallthrough
287+
case .EXT_X_SKIP:
281288
return GenericDictionaryTagParser(tag: pantostag)
282289

283290
// No Data tags
@@ -342,6 +349,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
342349
case .EXT_X_KEY:
343350
fallthrough
344351
case .EXT_X_DATERANGE:
352+
fallthrough
353+
case .EXT_X_SKIP:
345354
return GenericDictionaryTagWriter()
346355

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

465474
case .EXT_X_DATERANGE:
466475
return EXT_X_DATERANGETagValidator()
467-
476+
477+
case .EXT_X_SKIP:
478+
return GenericDictionaryTagValidator(tag: pantostag, dictionaryValueIdentifiers: [
479+
HLSDictionaryTagValueIdentifierImpl(valueId: PantosValue.skippedSegments,
480+
optional: false,
481+
expectedType: Int.self),
482+
HLSDictionaryTagValueIdentifierImpl(valueId: PantosValue.recentlyRemovedDateranges,
483+
optional: true,
484+
expectedType: String.self)
485+
])
486+
468487
case .Location:
469488
return nil
470489

@@ -510,7 +529,8 @@ extension PantosTag: HLSTagDescriptor, Equatable {
510529
PantosTag.EXT_X_START,
511530
PantosTag.EXT_X_DISCONTINUITY,
512531
PantosTag.EXT_X_BITRATE,
513-
PantosTag.EXT_X_DATERANGE]
532+
PantosTag.EXT_X_DATERANGE,
533+
PantosTag.EXT_X_SKIP]
514534

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

mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosValue.swift

+16-1
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,22 @@ public enum PantosValue: String {
189189
/// after the START-DATE of the range in question. This attribute is
190190
/// OPTIONAL.
191191
case endOnNext = "END-ON-NEXT"
192-
192+
193+
/// Found in `.EXT_X_SKIP`.
194+
///
195+
/// The value is a decimal-integer specifying the number of Media
196+
/// Segments replaced by the EXT-X-SKIP tag. This attribute is
197+
/// REQUIRED.
198+
case skippedSegments = "SKIPPED-SEGMENTS"
199+
200+
/// Found in `.EXT_X_SKIP`.
201+
///
202+
/// The value is a quoted-string consisting of a tab (0x9) delimited
203+
/// list of EXT-X-DATERANGE IDs that have been removed from the
204+
/// Playlist recently. See Section 6.2.5.1 for more information.
205+
/// This attribute is REQUIRED if the Client requested an update that
206+
/// skips EXT-X-DATERANGE tags. The quoted-string MAY be empty.
207+
case recentlyRemovedDateranges = "RECENTLY-REMOVED-DATERANGES"
193208
}
194209

195210
extension PantosValue: HLSTagValueIdentifier {

mambaTests/HLSPlaylistStructureAndEditingTests.swift

+42
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,26 @@ class HLSPlaylistStructureAndEditingTests: XCTestCase {
832832
XCTAssert(playlist.footer?.range.count == 1, "Should have a footer")
833833
XCTAssert(playlist.mediaSpans.count == 2, "Should have 2 spans")
834834
}
835+
836+
func testDeltaUpdateCorrectlyCalculatesMediaSequencesInTagGroups() {
837+
let playlist = parsePlaylist(inString: sampleDeltaUpdatePlaylist)
838+
839+
XCTAssertEqual(playlist.header?.range.count, 5, "Should have a header including 'server-control' and 'skip'")
840+
XCTAssertEqual(playlist.mediaSegmentGroups.count, 6, "Should have 6 remaining groups")
841+
for i in 0..<6 {
842+
guard playlist.mediaSegmentGroups.indices.contains(i) else {
843+
return XCTFail("Should have media segment group at index \(i)")
844+
}
845+
let group = playlist.mediaSegmentGroups[i]
846+
XCTAssertEqual(
847+
group.mediaSequence,
848+
i + 5,
849+
"Should have media sequence value equal to index (\(i)) + initial media sequence (1) + skipped (4)"
850+
)
851+
}
852+
XCTAssertNil(playlist.footer, "Should have no footer")
853+
XCTAssertEqual(playlist.mediaSpans.count, 0, "Should have no spans (no key tags)")
854+
}
835855
}
836856

837857

@@ -937,3 +957,25 @@ let sample4SegmentPlaylist =
937957
"#EXTINF:2.002,\n" +
938958
"http://not.a.server.nowhere/segment4.ts\n" +
939959
"#EXT-X-ENDLIST\n"
960+
961+
let sampleDeltaUpdatePlaylist =
962+
"""
963+
#EXTM3U
964+
#EXT-X-VERSION:9
965+
#EXT-X-MEDIA-SEQUENCE:1
966+
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=12
967+
#EXT-X-TARGETDURATION:2
968+
#EXT-X-SKIP:SKIPPED-SEGMENTS=4
969+
#EXTINF:2.002,
970+
http://not.a.server.nowhere/segment5.ts
971+
#EXTINF:2.002,
972+
http://not.a.server.nowhere/segment6.ts
973+
#EXTINF:2.002,
974+
http://not.a.server.nowhere/segment7.ts
975+
#EXTINF:2.002,
976+
http://not.a.server.nowhere/segment8.ts
977+
#EXTINF:2.002,
978+
http://not.a.server.nowhere/segment9.ts
979+
#EXTINF:2.002,
980+
http://not.a.server.nowhere/segment10.ts
981+
"""

mambaTests/PantosTagTests.swift

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class PantosTagTests: XCTestCase {
4343
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_ENDLIST)
4444
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_BITRATE)
4545
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_DATERANGE)
46+
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_SKIP)
4647

4748
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_INDEPENDENT_SEGMENTS)
4849
runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_START)
@@ -101,6 +102,8 @@ class PantosTagTests: XCTestCase {
101102
case .EXTINF:
102103
fallthrough
103104
case .EXT_X_DATERANGE:
105+
fallthrough
106+
case .EXT_X_SKIP:
104107
let stringRef = HLSStringRef(string: "#\(descriptor.toString())")
105108
guard let newDescriptor = PantosTag.constructDescriptor(fromStringRef: stringRef) else {
106109
XCTFail("PantosTag \(descriptor.toString()) is missing from stringRefLookup table.")

mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift

+42
Original file line numberDiff line numberDiff line change
@@ -797,4 +797,46 @@ class GenericDictionaryTagValidatorTests: XCTestCase {
797797
"Expected EXT-X-DATERANGE validation issue (\(expectedValidationIssue.description)) had unexpected severity (\(matchingIssue.severity))")
798798
}
799799
}
800+
801+
/*
802+
A server produces a Playlist Delta Update (Section 6.2.5.1), by
803+
replacing tags earlier than the Skip Boundary with an EXT-X-SKIP tag.
804+
805+
When replacing Media Segments, the EXT-X-SKIP tag replaces the
806+
segment URI lines and all Media Segment Tags tags that are applied to
807+
those segments. This tag MUST NOT appear more than once in a
808+
Playlist.
809+
810+
Its format is:
811+
812+
#EXT-X-SKIP:<attribute-list>
813+
814+
The following attributes are defined:
815+
816+
SKIPPED-SEGMENTS
817+
818+
The value is a decimal-integer specifying the number of Media
819+
Segments replaced by the EXT-X-SKIP tag. This attribute is
820+
REQUIRED.
821+
822+
RECENTLY-REMOVED-DATERANGES
823+
824+
The value is a quoted-string consisting of a tab (0x9) delimited
825+
list of EXT-X-DATERANGE IDs that have been removed from the
826+
Playlist recently. See Section 6.2.5.1 for more information.
827+
This attribute is REQUIRED if the Client requested an update that
828+
skips EXT-X-DATERANGE tags. The quoted-string MAY be empty.
829+
*/
830+
func test_EXT_X_SKIP() {
831+
let tagData = "SKIPPED-SEGMENTS=10,RECENTLY-REMOVED-DATERANGES=\"\""
832+
let optional: [PantosValue] = [.recentlyRemovedDateranges]
833+
let mandatory: [PantosValue] = [.skippedSegments]
834+
let badValues: [PantosValue] = [.skippedSegments]
835+
836+
validate(tag: PantosTag.EXT_X_SKIP,
837+
tagData: tagData,
838+
optional: optional,
839+
mandatory: mandatory,
840+
badValues: badValues)
841+
}
800842
}

0 commit comments

Comments
 (0)