From 37d24689094c4089b2a202ee55c6ff6d196a720b Mon Sep 17 00:00:00 2001 From: theRealRobG Date: Sat, 31 Aug 2024 14:28:45 -0400 Subject: [PATCH 1/5] Added failing test for media sequence calculation with skip --- .../HLSPlaylistStructureAndEditingTests.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/mambaTests/HLSPlaylistStructureAndEditingTests.swift b/mambaTests/HLSPlaylistStructureAndEditingTests.swift index 10da15b..e710a9f 100644 --- a/mambaTests/HLSPlaylistStructureAndEditingTests.swift +++ b/mambaTests/HLSPlaylistStructureAndEditingTests.swift @@ -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)") + } } @@ -937,3 +957,23 @@ let sample4SegmentPlaylist = "#EXTINF:2.002,\n" + "http://not.a.server.nowhere/segment4.ts\n" + "#EXT-X-ENDLIST\n" + +let sampleDeltaUpdatePlaylist = + "#EXTM3U\n" + + "#EXT-X-VERSION:9\n" + + "#EXT-X-MEDIA-SEQUENCE:1\n" + + "#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=12\n" + + "#EXT-X-TARGETDURATION:2\n" + + "#EXT-X-SKIP:SKIPPED-SEGMENTS=4\n" + + "#EXTINF:2.002,\n" + + "http://not.a.server.nowhere/segment5.ts\n" + + "#EXTINF:2.002,\n" + + "http://not.a.server.nowhere/segment6.ts\n" + + "#EXTINF:2.002,\n" + + "http://not.a.server.nowhere/segment7.ts\n" + + "#EXTINF:2.002,\n" + + "http://not.a.server.nowhere/segment8.ts\n" + + "#EXTINF:2.002,\n" + + "http://not.a.server.nowhere/segment9.ts\n" + + "#EXTINF:2.002,\n" + +"http://not.a.server.nowhere/segment10.ts\n" From e8bf3acc31ecfe727fb7860b1dcfd7cfd11fb9c5 Mon Sep 17 00:00:00 2001 From: theRealRobG Date: Sat, 31 Aug 2024 15:11:10 -0400 Subject: [PATCH 2/5] Added EXT-X-SKIP tag --- .../PantosTag.swift | 24 ++++++++++- .../PantosValue.swift | 17 +++++++- mambaTests/PantosTagTests.swift | 3 ++ .../GenericDictionaryTagValidatorTests.swift | 42 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosTag.swift b/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosTag.swift index a2b19a6..abd8ed7 100644 --- a/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosTag.swift +++ b/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosTag.swift @@ -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 { @@ -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: @@ -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: @@ -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 @@ -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. @@ -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 @@ -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)]]() diff --git a/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosValue.swift b/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosValue.swift index 3fc799b..ea161c8 100644 --- a/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosValue.swift +++ b/mambaSharedFramework/Pantos-Generic HLS Playlist Parsing/PantosValue.swift @@ -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 { diff --git a/mambaTests/PantosTagTests.swift b/mambaTests/PantosTagTests.swift index 69c1850..c37773a 100644 --- a/mambaTests/PantosTagTests.swift +++ b/mambaTests/PantosTagTests.swift @@ -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) @@ -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.") diff --git a/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift b/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift index 3293895..c003771 100644 --- a/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift +++ b/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift @@ -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: + + 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) + } } From ea3fa9c1e8089ed51691c3489f9f6597eb934be0 Mon Sep 17 00:00:00 2001 From: theRealRobG Date: Sat, 31 Aug 2024 15:11:50 -0400 Subject: [PATCH 3/5] Accounted for skip tags in media sequence calculation --- .../HLSPlaylistStructure.swift | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift b/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift index 6894652..525e22d 100644 --- a/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift +++ b/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift @@ -372,16 +372,44 @@ fileprivate struct HLSPlaylistStructureConstructor { var currentSegmentDuration: CMTime = CMTime.invalid var discontinuity = false let tagDescriptor = self.tagDescriptor(forTags: tags) - + + // collect indices for media sequence and skip tags as they impact the initial media sequence value + var mediaSequenceTagIndices = [Int]() + var skipTagIndices = [Int]() + tags.enumerated().forEach { + switch $0.element.tagDescriptor { + case PantosTag.EXT_X_MEDIA_SEQUENCE: mediaSequenceTagIndices.append($0.offset) + case PantosTag.EXT_X_SKIP: skipTagIndices.append($0.offset) + default: break + } + } + // 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) { + if mediaSequenceTagIndices.count > 0 { + assert(mediaSequenceTagIndices.count == 1, "Unexpected to have more than one media sequence") + if + let mediaSequenceIndex = mediaSequenceTagIndices.first, + let startMediaSequence: MediaSequence = tags[mediaSequenceIndex].value( + forValueIdentifier: PantosValue.sequence + ) + { currentMediaSequence = startMediaSequence } } - + + // account for any skip tags (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 skipTagIndices.count > 0 { + assert(skipTagIndices.count == 1, "Unexpected to have more than one skip tag") + if + let skipTagIndex = skipTagIndices.first, + let skippedSegments: Int = tags[skipTagIndex].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 }) From e6e31cafc326b3a5762d57515f25694814941361 Mon Sep 17 00:00:00 2001 From: theRealRobG Date: Tue, 3 Sep 2024 22:23:57 -0400 Subject: [PATCH 4/5] Updated test sample manifest for readability --- .../HLSPlaylistStructureAndEditingTests.swift | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/mambaTests/HLSPlaylistStructureAndEditingTests.swift b/mambaTests/HLSPlaylistStructureAndEditingTests.swift index e710a9f..e1b735e 100644 --- a/mambaTests/HLSPlaylistStructureAndEditingTests.swift +++ b/mambaTests/HLSPlaylistStructureAndEditingTests.swift @@ -959,21 +959,23 @@ let sample4SegmentPlaylist = "#EXT-X-ENDLIST\n" let sampleDeltaUpdatePlaylist = - "#EXTM3U\n" + - "#EXT-X-VERSION:9\n" + - "#EXT-X-MEDIA-SEQUENCE:1\n" + - "#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=12\n" + - "#EXT-X-TARGETDURATION:2\n" + - "#EXT-X-SKIP:SKIPPED-SEGMENTS=4\n" + - "#EXTINF:2.002,\n" + - "http://not.a.server.nowhere/segment5.ts\n" + - "#EXTINF:2.002,\n" + - "http://not.a.server.nowhere/segment6.ts\n" + - "#EXTINF:2.002,\n" + - "http://not.a.server.nowhere/segment7.ts\n" + - "#EXTINF:2.002,\n" + - "http://not.a.server.nowhere/segment8.ts\n" + - "#EXTINF:2.002,\n" + - "http://not.a.server.nowhere/segment9.ts\n" + - "#EXTINF:2.002,\n" + -"http://not.a.server.nowhere/segment10.ts\n" +""" +#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 +""" From d7c4e21269820780d497c0fdb9992a31e5964cc6 Mon Sep 17 00:00:00 2001 From: theRealRobG Date: Tue, 3 Sep 2024 22:49:26 -0400 Subject: [PATCH 5/5] Updated media sequence calculation logic to avoid unnecessary looping --- .../HLSPlaylistStructure.swift | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift b/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift index 525e22d..b433e5f 100644 --- a/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift +++ b/mambaSharedFramework/HLS Models/Playlist Structure/HLSPlaylistStructure.swift @@ -373,41 +373,41 @@ fileprivate struct HLSPlaylistStructureConstructor { var discontinuity = false let tagDescriptor = self.tagDescriptor(forTags: tags) - // collect indices for media sequence and skip tags as they impact the initial media sequence value - var mediaSequenceTagIndices = [Int]() - var skipTagIndices = [Int]() - tags.enumerated().forEach { - switch $0.element.tagDescriptor { - case PantosTag.EXT_X_MEDIA_SEQUENCE: mediaSequenceTagIndices.append($0.offset) - case PantosTag.EXT_X_SKIP: skipTagIndices.append($0.offset) - default: break + // 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 1 if not specified) - if mediaSequenceTagIndices.count > 0 { - assert(mediaSequenceTagIndices.count == 1, "Unexpected to have more than one media sequence") - if - let mediaSequenceIndex = mediaSequenceTagIndices.first, - let startMediaSequence: MediaSequence = tags[mediaSequenceIndex].value( - forValueIdentifier: PantosValue.sequence - ) - { - currentMediaSequence = startMediaSequence - } + // 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 tags (since a delta update replaces all segments earlier than the skip boundary, the + // 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 skipTagIndices.count > 0 { - assert(skipTagIndices.count == 1, "Unexpected to have more than one skip tag") - if - let skipTagIndex = skipTagIndices.first, - let skippedSegments: Int = tags[skipTagIndex].value(forValueIdentifier: PantosValue.skippedSegments) - { - currentMediaSequence += skippedSegments - } + if let skippedSegments: Int = skipTag?.value(forValueIdentifier: PantosValue.skippedSegments) { + currentMediaSequence += skippedSegments } // find the "header" portion by finding the first ".mediaSegment" scoped tag