diff --git a/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosTag.swift b/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosTag.swift index 28c6c74..4278967 100644 --- a/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosTag.swift +++ b/mambaSharedFramework/Pantos-Generic 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: PlaylistTagDescriptor, Equatable { @@ -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: @@ -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: @@ -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 @@ -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. @@ -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 @@ -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)]]() diff --git a/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosValue.swift b/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosValue.swift index 085547e..a6d7c8a 100644 --- a/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosValue.swift +++ b/mambaSharedFramework/Pantos-Generic 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: PlaylistTagValueIdentifier { diff --git a/mambaSharedFramework/Playlist Models/Playlist Structure/PlaylistStructureCore.swift b/mambaSharedFramework/Playlist Models/Playlist Structure/PlaylistStructureCore.swift index 17cb69b..cd8a21a 100644 --- a/mambaSharedFramework/Playlist Models/Playlist Structure/PlaylistStructureCore.swift +++ b/mambaSharedFramework/Playlist Models/Playlist Structure/PlaylistStructureCore.swift @@ -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 }) diff --git a/mambaTests/PantosTagTests.swift b/mambaTests/PantosTagTests.swift index be5450d..1d12b68 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 = MambaStringRef(string: "#\(descriptor.toString())") guard let newDescriptor = PantosTag.constructDescriptor(fromStringRef: stringRef) else { XCTFail("PantosTag \(descriptor.toString()) is missing from stringRefLookup table.") diff --git a/mambaTests/PlaylistStructureAndEditingTests.swift b/mambaTests/PlaylistStructureAndEditingTests.swift index 18db027..2fe3ea3 100644 --- a/mambaTests/PlaylistStructureAndEditingTests.swift +++ b/mambaTests/PlaylistStructureAndEditingTests.swift @@ -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)") + } } @@ -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 +""" diff --git a/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift b/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift index b6ac795..e2271e1 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) + } }