Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce EXT-X-SKIP support (Targeting version 2.x) #131

Merged
merged 6 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: PlaylistTagDescriptor, Equatable {
Expand Down Expand Up @@ -139,6 +140,8 @@ extension PantosTag: PlaylistTagDescriptor, 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: PlaylistTagDescriptor, 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: PlaylistTagDescriptor, 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: PlaylistTagDescriptor, 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: PlaylistTagDescriptor, Equatable {

case .EXT_X_DATERANGE:
return EXT_X_DATERANGETagValidator()


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

case .Location:
return nil

Expand Down Expand Up @@ -510,7 +529,8 @@ extension PantosTag: PlaylistTagDescriptor, 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: MambaStringRef)]]()

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: PlaylistTagValueIdentifier {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,43 @@ struct PlaylistStructureConstructor {
var lastRecordedTime: CMTime = CMTime.invalid
var currentSegmentDuration: CMTime = CMTime.invalid
var discontinuity = false

// 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: PlaylistTag?
var skipTag: PlaylistTag?
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
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 = MambaStringRef(string: "#\(descriptor.toString())")
guard let newDescriptor = PantosTag.constructDescriptor(fromStringRef: stringRef) else {
XCTFail("PantosTag \(descriptor.toString()) is missing from stringRefLookup table.")
Expand Down
42 changes: 42 additions & 0 deletions mambaTests/PlaylistStructureAndEditingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,26 @@ fragment1.ts

XCTAssertEqual(playlist3.playlistType, .event)
}

func testDeltaUpdateCorrectlyCalculatesMediaSequencesInTagGroups() {
let playlist = parseVariantPlaylist(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 @@ -975,3 +995,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
"""
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)
}
}