diff --git a/mamba.podspec b/mamba.podspec index f5c9849..b3f2c5e 100644 --- a/mamba.podspec +++ b/mamba.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "mamba" -s.version = "2.0.3" +s.version = "2.2.0" s.license = { :type => 'Apache License, Version 2.0', :text => <<-LICENSE Copyright 2017 Comcast Cable Communications Management, LLC diff --git a/mamba.xcodeproj/project.pbxproj b/mamba.xcodeproj/project.pbxproj index 5446e36..8b01bee 100644 --- a/mamba.xcodeproj/project.pbxproj +++ b/mamba.xcodeproj/project.pbxproj @@ -26,6 +26,15 @@ 43DE4EFD1E564DBE00EEE800 /* EXT_X_MEDIARenditionINSTREAMIDValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE4EFC1E564DBE00EEE800 /* EXT_X_MEDIARenditionINSTREAMIDValidator.swift */; }; 43DE4F0D1E564FEE00EEE800 /* EXT_X_STARTTimeOffsetValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE4EFA1E564DA300EEE800 /* EXT_X_STARTTimeOffsetValidator.swift */; }; 43DE4F0E1E564FFE00EEE800 /* EXT_X_MEDIARenditionINSTREAMIDValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE4EFC1E564DBE00EEE800 /* EXT_X_MEDIARenditionINSTREAMIDValidator.swift */; }; + 6DD0A1AD242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD0A1AC242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift */; }; + 6DD0A1AE242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD0A1AC242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift */; }; + 6DD0A1AF242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD0A1AC242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift */; }; + 6DD0A1B1242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD0A1B0242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift */; }; + 6DD0A1B2242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD0A1B0242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift */; }; + 6DD0A1B3242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD0A1B0242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift */; }; + 6DD9C898242CCE4A00B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 in Resources */ = {isa = PBXBuildFile; fileRef = 6DD9C897242CCE4A00B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 */; }; + 6DD9C899242CCE5300B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 in Resources */ = {isa = PBXBuildFile; fileRef = 6DD9C897242CCE4A00B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 */; }; + 6DD9C89A242CCE5400B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 in Resources */ = {isa = PBXBuildFile; fileRef = 6DD9C897242CCE4A00B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 */; }; 883290561EA172170064588B /* MambaStringRefExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883290551EA172170064588B /* MambaStringRefExtensionTests.swift */; }; D44E03771E3BAC9F00126B52 /* PlaylistTag+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44E03761E3BAC9F00126B52 /* PlaylistTag+Util.swift */; }; D44E03781E3BAC9F00126B52 /* PlaylistTag+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44E03761E3BAC9F00126B52 /* PlaylistTag+Util.swift */; }; @@ -634,6 +643,9 @@ 3D933C192193367C0029069F /* EXT-X-BITRATETagParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EXT-X-BITRATETagParserTests.swift"; sourceTree = ""; }; 43DE4EFA1E564DA300EEE800 /* EXT_X_STARTTimeOffsetValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = EXT_X_STARTTimeOffsetValidator.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 43DE4EFC1E564DBE00EEE800 /* EXT_X_MEDIARenditionINSTREAMIDValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EXT_X_MEDIARenditionINSTREAMIDValidator.swift; sourceTree = ""; }; + 6DD0A1AC242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EXT_X_DATERANGETagValidator.swift; sourceTree = ""; }; + 6DD0A1B0242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EXT_X_DATERANGEPlaylistValidator.swift; sourceTree = ""; }; + 6DD9C897242CCE4A00B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 */ = {isa = PBXFileReference; lastKnownFileType = text; path = hls_variant_playlist_with_daterange_metadata.m3u8; sourceTree = ""; }; 883290551EA172170064588B /* MambaStringRefExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MambaStringRefExtensionTests.swift; sourceTree = ""; }; D44E03761E3BAC9F00126B52 /* PlaylistTag+Util.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PlaylistTag+Util.swift"; sourceTree = ""; }; D4BB018C1E2EABD500CA006E /* PlaylistTagArray+RenditionGroups.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PlaylistTagArray+RenditionGroups.swift"; sourceTree = ""; }; @@ -914,6 +926,8 @@ isa = PBXGroup; children = ( EC7491F71DD29DD300AF4E20 /* DictionaryTagValueIdentifier.swift */, + 6DD0A1B0242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift */, + 6DD0A1AC242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift */, EC3B019E1DD4D47900B512E3 /* EXT_X_KEYValidator.swift */, EC3B019F1DD4D47900B512E3 /* EXT_X_MEDIARenditionGroupAUTOSELECTValidator.swift */, EC3B01A01DD4D47900B512E3 /* EXT_X_MEDIARenditionGroupDEFAULTValidator.swift */, @@ -1234,6 +1248,7 @@ EC7492001DD29E2900AF4E20 /* hls_sampleMediaFile.txt */, EC7492011DD29E2900AF4E20 /* hls_singleMediaFile.txt */, 1D28F3441EAA9E500010320B /* hls_variant_playlist.m3u8 */, + 6DD9C897242CCE4A00B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 */, EC7492021DD29E2900AF4E20 /* hls_writer_parser_roundtrip_tester.txt */, EC7492061DD29E2900AF4E20 /* super8demuxed1_4242.m3u8 */, EC7492071DD29E2900AF4E20 /* super8demuxed2_IP_1080p24_51_TS.m3u8 */, @@ -1662,6 +1677,7 @@ EC7492201DD29E2900AF4E20 /* ThirdPartyTagsTestFixture.txt in Resources */, EC74920A1DD29E2900AF4E20 /* bipbopall.m3u8 in Resources */, EC1705421DFB490A00C969F9 /* linear_ad_insertion_CONTENT_end_ad_forced.m3u8 in Resources */, + 6DD9C898242CCE4A00B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 in Resources */, EC74921A1DD29E2900AF4E20 /* super8demuxed1_4242.m3u8 in Resources */, EC74921E1DD29E2900AF4E20 /* super8demuxed3_1376214110461.m3u8 in Resources */, EC74920C1DD29E2900AF4E20 /* hls_sampleMasterFile.txt in Resources */, @@ -1698,6 +1714,7 @@ EC7492211DD29E2900AF4E20 /* ThirdPartyTagsTestFixture.txt in Resources */, EC74920B1DD29E2900AF4E20 /* bipbopall.m3u8 in Resources */, EC1705431DFB490A00C969F9 /* linear_ad_insertion_CONTENT_end_ad_forced.m3u8 in Resources */, + 6DD9C899242CCE5300B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 in Resources */, EC74921B1DD29E2900AF4E20 /* super8demuxed1_4242.m3u8 in Resources */, EC74921F1DD29E2900AF4E20 /* super8demuxed3_1376214110461.m3u8 in Resources */, EC74920D1DD29E2900AF4E20 /* hls_sampleMasterFile.txt in Resources */, @@ -1734,6 +1751,7 @@ ECE253C0209A508700D388CE /* Super8_muxed1_4242.m3u8 in Resources */, ECE253CE209A508700D388CE /* super8demuxed1_4242.m3u8 in Resources */, ECE253CB209A508700D388CE /* hls_singleMediaFile.txt in Resources */, + 6DD9C89A242CCE5400B90E1C /* hls_variant_playlist_with_daterange_metadata.m3u8 in Resources */, ECE253C7209A508700D388CE /* hls_master_playlist.m3u8 in Resources */, ECE253BF209A508700D388CE /* linear_ad_insertion_CONTENT_start_ad.m3u8 in Resources */, ECE253BD209A508700D388CE /* linear_ad_insertion_CONTENT_end_ad_natural.m3u8 in Resources */, @@ -1778,6 +1796,7 @@ EC7491BF1DD29D5C00AF4E20 /* PlaylistTagValueIdentifier.swift in Sources */, EC3B01C91DD4D49A00B512E3 /* PlaylistRenditionGroupMatchingPROGRAM_IDValidator.swift in Sources */, EC349AC52236BFF10077432B /* PlaylistInterface.swift in Sources */, + 6DD0A1AD242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift in Sources */, F700CD331E78A0B9001C9487 /* MambaStringRef_ConcreteUnownedBytes.m in Sources */, EC95478B1E5CC86300962535 /* EXTINFValidator.swift in Sources */, EC9826021DD3A113003BCDA5 /* URLSchemeChangeExtension.swift in Sources */, @@ -1791,6 +1810,7 @@ EC03B6541E5CC56B00BF1F97 /* RapidParser.m in Sources */, F70E9E9A1E8C43C8006022C6 /* PlaylistParserError.swift in Sources */, EC7491701DD29B5D00AF4E20 /* CollectionType+Safe.swift in Sources */, + 6DD0A1B1242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift in Sources */, EC7491BB1DD29D5C00AF4E20 /* PlaylistTagParser.swift in Sources */, EC7491C11DD29D5C00AF4E20 /* PlaylistTagWriter.swift in Sources */, EC3B01CD1DD4D49A00B512E3 /* PlaylistTagCardinalityValidation.swift in Sources */, @@ -1948,6 +1968,7 @@ EC9547861E5CC83C00962535 /* NoOpTagParser.swift in Sources */, EC7491FB1DD29DD300AF4E20 /* GenericSingleTagValidator.swift in Sources */, EC349AC62236BFF10077432B /* PlaylistInterface.swift in Sources */, + 6DD0A1AE242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift in Sources */, F700CD341E78A0B9001C9487 /* MambaStringRef_ConcreteUnownedBytes.m in Sources */, EC95478C1E5CC86300962535 /* EXTINFValidator.swift in Sources */, EC7491C01DD29D5C00AF4E20 /* PlaylistTagValueIdentifier.swift in Sources */, @@ -1961,6 +1982,7 @@ EC03B65B1E5CC56B00BF1F97 /* MambaStringRef.m in Sources */, EC7491801DD29C3500AF4E20 /* String+EquatableMambaTypes.swift in Sources */, F70E9E9B1E8C43C8006022C6 /* PlaylistParserError.swift in Sources */, + 6DD0A1B2242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift in Sources */, EC03B6551E5CC56B00BF1F97 /* RapidParser.m in Sources */, EC7491711DD29B5D00AF4E20 /* CollectionType+Safe.swift in Sources */, EC7491BC1DD29D5C00AF4E20 /* PlaylistTagParser.swift in Sources */, @@ -2118,6 +2140,7 @@ EC1CCD4D209A2CF9006B59FF /* PlaylistRenditionGroupMatchingPROGRAM_IDValidator.swift in Sources */, EC1CCD24209A2CF9006B59FF /* CollectionType+Safe.swift in Sources */, EC1CCCF5209A2CF9006B59FF /* PlaylistTimelineTranslator.swift in Sources */, + 6DD0A1AF242F85C800FF7AAE /* EXT_X_DATERANGETagValidator.swift in Sources */, EC1CCD58209A2CF9006B59FF /* PantosValue.swift in Sources */, EC349AC72236BFF10077432B /* PlaylistInterface.swift in Sources */, EC1CCD63209A2CF9006B59FF /* Mamba.swift in Sources */, @@ -2131,6 +2154,7 @@ EC1CCD50209A2CF9006B59FF /* PlaylistTagGroupValidator.swift in Sources */, EC1CCCFE209A2CF9006B59FF /* MambaStringRefFactory.m in Sources */, EC349AD42236CB860077432B /* PlaylistStructureCore.swift in Sources */, + 6DD0A1B3242FADC600FF7AAE /* EXT_X_DATERANGEPlaylistValidator.swift in Sources */, EC1CCD3A209A2CF9006B59FF /* NoOpTagParser.swift in Sources */, EC1CCD5D209A2CF9006B59FF /* PlaylistTagValidator.swift in Sources */, EC1CCD62209A2CF9006B59FF /* PlaylistWriter.swift in Sources */, @@ -2300,7 +2324,7 @@ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.comcast.mamba; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -2327,7 +2351,7 @@ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.comcast.mamba; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -2391,7 +2415,7 @@ INFOPLIST_FILE = mamba/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.comcast.mamba; PRODUCT_NAME = mamba; SDKROOT = appletvos; @@ -2421,7 +2445,7 @@ INFOPLIST_FILE = mamba/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.comcast.mamba; PRODUCT_NAME = mamba; SDKROOT = appletvos; @@ -2499,7 +2523,7 @@ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; MACOSX_DEPLOYMENT_TARGET = 10.13; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.comcast.mamba; PRODUCT_MODULE_NAME = mamba; PRODUCT_NAME = mamba; @@ -2532,7 +2556,7 @@ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; MACOSX_DEPLOYMENT_TARGET = 10.13; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.comcast.mamba; PRODUCT_MODULE_NAME = mamba; PRODUCT_NAME = mamba; diff --git a/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/EXT_X_DATERANGEPlaylistValidator.swift b/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/EXT_X_DATERANGEPlaylistValidator.swift new file mode 100644 index 0000000..a08001e --- /dev/null +++ b/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/EXT_X_DATERANGEPlaylistValidator.swift @@ -0,0 +1,104 @@ +// +// EXT_X_DATERANGEPlaylistValidator.swift +// mamba +// +// Created by Robert Galluccio on 28/03/2020. +// Copyright © 2020 Comcast Corporation. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. All rights reserved. +// + +import Foundation + +/// This class provides the validation for EXT-X-DATERANGE tags for rules which require the whole playlist information. +/// +/// All validation issues for EXT-X-DATERANGE are treated as `.warning` due to this comment in the HLS specification: +/// +/// Clients SHOULD ignore EXT-X-DATERANGE tags with illegal syntax. +/// +class EXT_X_DATERANGEPlaylistValidator: VariantPlaylistValidator { + + static func validate(variantPlaylist: VariantPlaylistInterface) -> [PlaylistValidationIssue] { + + var programDateTimeTagsCount = 0 + var daterangeTags = [PlaylistTag]() + for tag in variantPlaylist.tags { + if tag.tagDescriptor == PantosTag.EXT_X_PROGRAM_DATE_TIME { + programDateTimeTagsCount += 1 + } else if tag.tagDescriptor == PantosTag.EXT_X_DATERANGE { + daterangeTags.append(tag) + } + } + + var validationIssues = [PlaylistValidationIssue]() + validationIssues.append(contentsOf: validateProgramDateTime(programDateTimeTagsCount: programDateTimeTagsCount, daterangeTagsCount: daterangeTags.count)) + validationIssues.append(contentsOf: validateMultipleTags(daterangeTags: daterangeTags)) + + return validationIssues + } + + // If a Playlist contains an EXT-X-DATERANGE tag, it MUST also contain + // at least one EXT-X-PROGRAM-DATE-TIME tag. + private static func validateProgramDateTime(programDateTimeTagsCount: Int, daterangeTagsCount: Int) -> [PlaylistValidationIssue] { + + guard daterangeTagsCount > 0, programDateTimeTagsCount == 0 else { + return [] + } + return [PlaylistValidationIssue(description: .EXT_X_DATERANGEExistsWithNoEXT_X_PROGRAM_DATE_TIME, severity: .warning)] + } + + // If a Playlist contains two EXT-X-DATERANGE tags with the same ID + // attribute value, then any AttributeName that appears in both tags + // MUST have the same AttributeValue. + private static func validateMultipleTags(daterangeTags: [PlaylistTag]) -> [PlaylistValidationIssue] { + + // first fill out a map of ID to tags to group tags with matching ID + var idTagMap = [String: [PlaylistTag]]() + for tag in daterangeTags { + guard let id = tag.value(forValueIdentifier: PantosValue.id) else { + // This should not happen, but if it does, this is a validation issue that will be caught by the tag validator + continue + } + if let matchingTags = idTagMap[id] { + idTagMap[id] = matchingTags + [tag] + } else { + idTagMap[id] = [tag] + } + } + // next we pick out ID values with multiple tags and validate the attributes match between them + var validationIssues = [PlaylistValidationIssue]() + for (_, tags) in idTagMap { + guard tags.count > 1 else { + continue + } + // make a map of attributes to values to ensure any matching attributes also match value + var attributeToValueMap = [String: String]() + for tag in tags { + for attribute in tag.keys { + guard let attributeValue = tag.value(forKey: attribute) else { + assertionFailure("tag.keys gave us a key that had no value in the tag - key: \(attribute), tag: \(tag), tag.keys: \(tag.keys)") + continue + } + if let matchingAttributeValue = attributeToValueMap[attribute] { + if attributeValue != matchingAttributeValue { + validationIssues.append(PlaylistValidationIssue(description: .EXT_X_DATERANGEAttributeMismatchForTagsWithSameID, severity: .warning)) + } + } else { + attributeToValueMap[attribute] = attributeValue + } + } + } + } + + return validationIssues + } +} diff --git a/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/EXT_X_DATERANGETagValidator.swift b/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/EXT_X_DATERANGETagValidator.swift new file mode 100644 index 0000000..9bd263a --- /dev/null +++ b/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/EXT_X_DATERANGETagValidator.swift @@ -0,0 +1,178 @@ +// +// EXT_X_DATERANGEValidator.swift +// mamba +// +// Created by Robert Galluccio on 28/03/2020. +// Copyright © 2020 Comcast Corporation. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. All rights reserved. +// + +import Foundation + +/// This class provides special validation for EXT-X-DATERANGE tags on top of the regular `GenericDictionaryTagValidator`. +/// +/// All validation issues for EXT-X-DATERANGE are treated as `.warning` due to this comment in the HLS specification: +/// +/// Clients SHOULD ignore EXT-X-DATERANGE tags with illegal syntax. +/// +class EXT_X_DATERANGETagValidator: PlaylistTagValidator { + + private let genericDictionaryTagValidator: GenericDictionaryTagValidator + + init() { + // All of the generic dictionary tag validation still applies; + // however there will need to be some extra validation on top for EXT-X-DATERANGE. + // Therefore, we will compose this validation and make use of the pre-existing + // GenericDictionaryTagValidator. + self.genericDictionaryTagValidator = GenericDictionaryTagValidator(tag: PantosTag.EXT_X_DATERANGE, dictionaryValueIdentifiers: [ + DictionaryTagValueIdentifierImpl(valueId: PantosValue.id, optional: false, expectedType: String.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.classAttribute, optional: true, expectedType: String.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.startDate, optional: false, expectedType: Date.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.endDate, optional: true, expectedType: Date.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.duration, optional: true, expectedType: Double.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.plannedDuration, optional: true, expectedType: Double.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.scte35Cmd, optional: true, expectedType: String.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.scte35Out, optional: true, expectedType: String.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.scte35In, optional: true, expectedType: String.self), + DictionaryTagValueIdentifierImpl(valueId: PantosValue.endOnNext, optional: true, expectedType: Bool.self) + ]) + } + + func validate(tag: PlaylistTag) -> [PlaylistValidationIssue]? { + + var validationIssues: [PlaylistValidationIssue]? + if let genericIssues = genericDictionaryTagValidator.validate(tag: tag) { + validationIssues = validationIssues ?? [] + // We re-map to have warning severity for any issue from the generic validator + // to ensure a date-range validation issue will not fail the manifest parsing. + let genericWarningIssues = genericIssues.map { PlaylistValidationIssue(description: $0.description, severity: .warning) } + validationIssues?.append(contentsOf: genericWarningIssues) + } + if let endOnNextIssues = endOnNextValidation(tag: tag) { + validationIssues = validationIssues ?? [] + validationIssues?.append(contentsOf: endOnNextIssues) + } + if let durationEndDateIssues = durationEndDateValidation(tag: tag) { + validationIssues = validationIssues ?? [] + validationIssues?.append(contentsOf: durationEndDateIssues) + } + if let endDateIssues = endDateValidation(tag: tag) { + validationIssues = validationIssues ?? [] + validationIssues?.append(contentsOf: endDateIssues) + } + if let negativeDurationIssues = negativeDurationValidation(tag: tag) { + validationIssues = validationIssues ?? [] + validationIssues?.append(contentsOf: negativeDurationIssues) + } + if let negativePlannedDurationIssues = negativePlannedDurationValidation(tag: tag) { + validationIssues = validationIssues ?? [] + validationIssues?.append(contentsOf: negativePlannedDurationIssues) + } + + return validationIssues + } + + // END-ON-NEXT + // + // An enumerated-string whose value MUST be YES. + // + // An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST have a + // CLASS attribute. + // + // An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST NOT + // contain DURATION or END-DATE attributes. + private func endOnNextValidation(tag: PlaylistTag) -> [PlaylistValidationIssue]? { + guard let endOnNext = tag.value(forValueIdentifier: PantosValue.endOnNext) as Bool? else { + return nil + } + + var validationIssues = [PlaylistValidationIssue]() + + // value MUST be YES. + if !endOnNext { + validationIssues.append(PlaylistValidationIssue(description: .EXT_X_DATERANGEEND_ON_NEXTValueMustBeYES, severity: .warning)) + } + + // An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST have a CLASS attribute. + if tag.value(forValueIdentifier: PantosValue.classAttribute) == nil { + validationIssues.append(PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustHaveCLASSAttribute, severity: .warning)) + } + + // An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST NOT contain DURATION or END-DATE attributes. + if tag.value(forValueIdentifier: PantosValue.duration) != nil { + validationIssues.append(PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainDURATION, severity: .warning)) + } + if tag.value(forValueIdentifier: PantosValue.endDate) != nil { + validationIssues.append(PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainEND_DATE, severity: .warning)) + } + + return validationIssues.isEmpty ? nil : validationIssues + } + + // If a Date Range contains both a DURATION attribute and an END-DATE + // attribute, the value of the END-DATE attribute MUST be equal to the + // value of the START-DATE attribute plus the value of the DURATION + // attribute. + private func durationEndDateValidation(tag: PlaylistTag) -> [PlaylistValidationIssue]? { + guard let startDate = tag.value(forValueIdentifier: PantosValue.startDate) as Date? else { + // The failure will be caught in the generic validation, since start date is required. + return nil + } + guard let duration = tag.value(forValueIdentifier: PantosValue.duration) as Double?, + let endDate = tag.value(forValueIdentifier: PantosValue.endDate) as Date? else { + return nil + } + + var validationIssues = [PlaylistValidationIssue]() + let expectedEndDate = startDate.addingTimeInterval(duration) + if expectedEndDate != endDate { + validationIssues.append(PlaylistValidationIssue(description: .EXT_X_DATERANGEValidatorDURATIONAndEND_DATEMustMatchWithSTART_DATE, + severity: .warning)) + } + + return validationIssues.isEmpty ? nil : validationIssues + } + + // END-DATE MUST be equal to or later than the value of the START-DATE attribute. + private func endDateValidation(tag: PlaylistTag) -> [PlaylistValidationIssue]? { + guard let startDate = tag.value(forValueIdentifier: PantosValue.startDate) as Date? else { + // The failure will be caught in the generic validation, since start date is required. + return nil + } + guard let endDate = tag.value(forValueIdentifier: PantosValue.endDate) as Date? else { + return nil + } + switch startDate.compare(endDate) { + case .orderedAscending, .orderedSame: + return nil + case .orderedDescending: + return [PlaylistValidationIssue(description: .EXT_X_DATERANGETagEND_DATEMustBeAfterSTART_DATE, severity: .warning)] + } + } + + // DURATION MUST NOT be negative. + private func negativeDurationValidation(tag: PlaylistTag) -> [PlaylistValidationIssue]? { + guard let duration = tag.value(forValueIdentifier: PantosValue.duration) as Double?, duration < 0 else { + return nil + } + return [PlaylistValidationIssue(description: .EXT_X_DATERANGETagDURATIONMustNotBeNegative, severity: .warning)] + } + + // PLANNED-DURATION MUST NOT be negative. + private func negativePlannedDurationValidation(tag: PlaylistTag) -> [PlaylistValidationIssue]? { + guard let plannedDuration = tag.value(forValueIdentifier: PantosValue.plannedDuration) as Double?, plannedDuration < 0 else { + return nil + } + return [PlaylistValidationIssue(description: .EXT_X_DATERANGETagPLANNED_DURATIONMustNotBeNegative, severity: .warning)] + } +} diff --git a/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/PlaylistValidator.swift b/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/PlaylistValidator.swift index cf6e5e4..8a1b2bf 100644 --- a/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/PlaylistValidator.swift +++ b/mambaSharedFramework/Pantos-Generic Playlist Parsing/Pantos-Generic Tag Validators/PlaylistValidator.swift @@ -69,5 +69,6 @@ public class PlaylistValidator: ExtensiblePlaylistValidator { public static let variantPlaylistValidators: [VariantPlaylistValidator.Type] = [PlaylistAggregateTagCardinalityValidator.self, EXT_X_TARGETDURATIONLengthValidator.self, - EXT_X_STARTTimeOffsetValidator.self] + EXT_X_STARTTimeOffsetValidator.self, + EXT_X_DATERANGEPlaylistValidator.self] } diff --git a/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosTag.swift b/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosTag.swift index e6d98ed..28c6c74 100644 --- a/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosTag.swift +++ b/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosTag.swift @@ -74,6 +74,9 @@ public enum PantosTag: String { case EXT_X_PROGRAM_DATE_TIME = "EXT-X-PROGRAM-DATE-TIME" case EXT_X_DISCONTINUITY = "EXT-X-DISCONTINUITY" case EXT_X_DISCONTINUITY_SEQUENCE = "EXT-X-DISCONTINUITY-SEQUENCE" + + // MARK: Variant playlist - Media metadata tags + case EXT_X_DATERANGE = "EXT-X-DATERANGE" } extension PantosTag: PlaylistTagDescriptor, Equatable { @@ -134,6 +137,8 @@ extension PantosTag: PlaylistTagDescriptor, Equatable { case .EXT_X_DISCONTINUITY_SEQUENCE: fallthrough case .EXT_X_TARGETDURATION: + fallthrough + case .EXT_X_DATERANGE: return .wholePlaylist case .EXT_X_BITRATE: @@ -197,6 +202,8 @@ extension PantosTag: PlaylistTagDescriptor, Equatable { case .EXT_X_START: fallthrough case .EXT_X_KEY: + fallthrough + case .EXT_X_DATERANGE: return .keyValue case .Location: @@ -269,6 +276,8 @@ extension PantosTag: PlaylistTagDescriptor, Equatable { case .EXT_X_START: fallthrough case .EXT_X_KEY: + fallthrough + case .EXT_X_DATERANGE: return GenericDictionaryTagParser(tag: pantostag) // No Data tags @@ -331,6 +340,8 @@ extension PantosTag: PlaylistTagDescriptor, Equatable { case .EXT_X_START: fallthrough case .EXT_X_KEY: + fallthrough + case .EXT_X_DATERANGE: return GenericDictionaryTagWriter() // These tags cannot be modified and therefore these cases are invalid. @@ -451,6 +462,9 @@ extension PantosTag: PlaylistTagDescriptor, Equatable { DictionaryTagValueIdentifierImpl(valueId: PantosValue.precise, optional: true, expectedType: Bool.self) ]) + case .EXT_X_DATERANGE: + return EXT_X_DATERANGETagValidator() + case .Location: return nil @@ -495,7 +509,8 @@ extension PantosTag: PlaylistTagDescriptor, Equatable { PantosTag.EXT_X_INDEPENDENT_SEGMENTS, PantosTag.EXT_X_START, PantosTag.EXT_X_DISCONTINUITY, - PantosTag.EXT_X_BITRATE] + PantosTag.EXT_X_BITRATE, + PantosTag.EXT_X_DATERANGE] 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 4eb77dd..085547e 100644 --- a/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosValue.swift +++ b/mambaSharedFramework/Pantos-Generic Playlist Parsing/PantosValue.swift @@ -133,6 +133,63 @@ public enum PantosValue: String { /// Found in `.EXT_X_START`. Indicates client SHOULD NOT render media samples in that segment whose presentation times are prior to the TIME-OFFSET (YES or NO) case precise = "PRECISE" + /// Found in `.EXT_X_DATERANGE`. + /// A quoted-string that uniquely identifies a Date Range in the + /// Playlist. This attribute is REQUIRED + case id = "ID" + + /// Found in `.EXT_X_DATERANGE`. + /// A client-defined quoted-string that specifies some set of + /// attributes and their associated value semantics. All Date Ranges + /// with the same CLASS attribute value MUST adhere to these + /// semantics. This attribute is OPTIONAL. + case classAttribute = "CLASS" + + /// Found in `.EXT_X_DATERANGE`. + /// A quoted-string containing the [ISO_8601] date/time at which the + /// Date Range begins. This attribute is REQUIRED. + case startDate = "START-DATE" + + /// Found in `.EXT_X_DATERANGE`. + /// A quoted-string containing the [ISO_8601] date/time at which the + /// Date Range ends. It MUST be equal to or later than the value of + /// the START-DATE attribute. This attribute is OPTIONAL. + case endDate = "END-DATE" + + /// Found in `.EXT_X_DATERANGE`. + /// The duration of the Date Range expressed as a decimal-floating- + /// point number of seconds. It MUST NOT be negative. A single + /// instant in time (e.g., crossing a finish line) SHOULD be + /// represented with a duration of 0. This attribute is OPTIONAL. + case duration = "DURATION" + + /// Found in `.EXT_X_DATERANGE`. + /// The expected duration of the Date Range expressed as a decimal- + /// floating-point number of seconds. It MUST NOT be negative. This + /// attribute SHOULD be used to indicate the expected duration of a + /// Date Range whose actual duration is not yet known. It is + /// OPTIONAL. + case plannedDuration = "PLANNED-DURATION" + + /// Found in `.EXT_X_DATERANGE`. + /// Used to carry SCTE-35 data. These attributes are OPTIONAL. + case scte35Cmd = "SCTE35-CMD" + /// Found in `.EXT_X_DATERANGE`. + /// Used to carry SCTE-35 data. These attributes are OPTIONAL. + case scte35Out = "SCTE35-OUT" + /// Found in `.EXT_X_DATERANGE`. + /// Used to carry SCTE-35 data. These attributes are OPTIONAL. + case scte35In = "SCTE35-IN" + + /// Found in `.EXT_X_DATERANGE`. + /// An enumerated-string whose value MUST be YES. This attribute + /// indicates that the end of the range containing it is equal to the + /// START-DATE of its Following Range. The Following Range is the + /// Date Range of the same CLASS that has the earliest START-DATE + /// after the START-DATE of the range in question. This attribute is + /// OPTIONAL. + case endOnNext = "END-ON-NEXT" + } extension PantosValue: PlaylistTagValueIdentifier { diff --git a/mambaSharedFramework/PlaylistValidationIssue.swift b/mambaSharedFramework/PlaylistValidationIssue.swift index 832c8d4..4607a6e 100644 --- a/mambaSharedFramework/PlaylistValidationIssue.swift +++ b/mambaSharedFramework/PlaylistValidationIssue.swift @@ -85,5 +85,15 @@ public enum IssueDescription: String { case EXT_X_DISCONTINUITY_SEQUENCEValidator = "A Playlist file MUST NOT contain more than one EXT-X-DISCONTINUITY-SEQUENCE tag." case EXT_X_MEDIARenditionGroupDEFAULTValidator = "A group MUST NOT have more than one member with a DEFAULT attribute of YES." case EXTINFTagsRequireADurationValidator = "EXTINF tags require a positive duration." + case EXT_X_DATERANGEEND_ON_NEXTValueMustBeYES = "Value of END-ON-NEXT attribute within EXT-X-DATERANGE MUST be YES." + case EXT_X_DATERANGETagWithEND_ON_NEXTMustHaveCLASSAttribute = "An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST have a CLASS attribute." + case EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainDURATION = "An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST NOT contain DURATION attribute." + case EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainEND_DATE = "An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST NOT contain END-DATE attribute." + case EXT_X_DATERANGEValidatorDURATIONAndEND_DATEMustMatchWithSTART_DATE = "If a Date Range contains both a DURATION attribute and an END-DATE attribute, the value of the END-DATE attribute MUST be equal to the value of the START-DATE attribute plus the value of the DURATION attribute." + case EXT_X_DATERANGETagEND_DATEMustBeAfterSTART_DATE = "END-DATE MUST be equal to or later than the value of the START-DATE attribute." + case EXT_X_DATERANGETagDURATIONMustNotBeNegative = "DURATION MUST NOT be negative." + case EXT_X_DATERANGETagPLANNED_DURATIONMustNotBeNegative = "PLANNED-DURATION MUST NOT be negative." + case EXT_X_DATERANGEExistsWithNoEXT_X_PROGRAM_DATE_TIME = "If a Playlist contains an EXT-X-DATERANGE tag, it MUST also contain at least one EXT-X-PROGRAM-DATE-TIME tag." + case EXT_X_DATERANGEAttributeMismatchForTagsWithSameID = "If a Playlist contains two EXT-X-DATERANGE tags with the same ID attribute value, then any AttributeName that appears in both tags MUST have the same AttributeValue." } diff --git a/mambaTests/BasicParserTest.swift b/mambaTests/BasicParserTest.swift index dc2ba53..f1f380e 100644 --- a/mambaTests/BasicParserTest.swift +++ b/mambaTests/BasicParserTest.swift @@ -217,6 +217,120 @@ class BasicParserTest: XCTestCase { XCTAssert(validationIssues.count == 0, "Should be no issues in the HLS fixtures") } + func testHLSVariantPlaylistWithDaterangeMetadata() { + let hlsLoadString = FixtureLoader.loadAsString(fixtureName: "hls_variant_playlist_with_daterange_metadata.m3u8") + + guard let hlsString = hlsLoadString else { + XCTAssert(false, "Fixture is missing?") + return + } + let playlist = parseVariantPlaylist(inString: hlsString) + + XCTAssert(playlist.tags.count == 30, "Misparsed the HLS") + + XCTAssert(playlist.tags[0].tagDescriptor == PantosTag.EXT_X_VERSION, "Tag did not parse properly") + XCTAssert(playlist.tags[1].tagDescriptor == PantosTag.EXT_X_TARGETDURATION, "Tag did not parse properly") + XCTAssert(playlist.tags[2].tagDescriptor == PantosTag.EXT_X_MEDIA_SEQUENCE, "Tag did not parse properly") + XCTAssert(playlist.tags[3].tagDescriptor == PantosTag.EXT_X_DISCONTINUITY_SEQUENCE, "Tag did not parse properly") + XCTAssert(playlist.tags[4].tagDescriptor == PantosTag.EXT_X_PROGRAM_DATE_TIME, "Tag did not parse properly") + XCTAssert(playlist.tags[5].tagDescriptor == PantosTag.EXT_X_KEY, "Tag did not parse properly") + XCTAssert(playlist.tags[6].tagDescriptor == PantosTag.EXT_X_MAP, "Tag did not parse properly") + XCTAssert(playlist.tags[7].tagDescriptor == PantosTag.EXT_X_DATERANGE, "Tag did not parse properly") + XCTAssert(playlist.tags[8].tagDescriptor == PantosTag.EXT_X_DATERANGE, "Tag did not parse properly") + XCTAssert(playlist.tags[9].tagDescriptor == PantosTag.EXT_X_DATERANGE, "Tag did not parse properly") + XCTAssert(playlist.tags[10].tagDescriptor == PantosTag.EXT_X_DATERANGE, "Tag did not parse properly") + XCTAssert(playlist.tags[11].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[12].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + XCTAssert(playlist.tags[13].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[14].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + XCTAssert(playlist.tags[15].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[16].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + XCTAssert(playlist.tags[17].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[18].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + XCTAssert(playlist.tags[19].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[20].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + XCTAssert(playlist.tags[21].tagDescriptor == PantosTag.EXT_X_DATERANGE, "Tag did not parse properly") + XCTAssert(playlist.tags[22].tagDescriptor == PantosTag.EXT_X_DATERANGE, "Tag did not parse properly") + XCTAssert(playlist.tags[23].tagDescriptor == PantosTag.EXT_X_DATERANGE, "Tag did not parse properly") + XCTAssert(playlist.tags[24].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[25].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + XCTAssert(playlist.tags[26].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[27].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + XCTAssert(playlist.tags[28].tagDescriptor == PantosTag.EXTINF, "Tag did not parse properly") + XCTAssert(playlist.tags[29].tagDescriptor == PantosTag.Location, "Tag did not parse properly") + + XCTAssert(playlist.tags[0].tagName! == "#\(PantosTag.EXT_X_VERSION.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[1].tagName! == "#\(PantosTag.EXT_X_TARGETDURATION.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[2].tagName! == "#\(PantosTag.EXT_X_MEDIA_SEQUENCE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[3].tagName! == "#\(PantosTag.EXT_X_DISCONTINUITY_SEQUENCE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[4].tagName! == "#\(PantosTag.EXT_X_PROGRAM_DATE_TIME.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[5].tagName! == "#\(PantosTag.EXT_X_KEY.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[6].tagName! == "#\(PantosTag.EXT_X_MAP.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[7].tagName! == "#\(PantosTag.EXT_X_DATERANGE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[8].tagName! == "#\(PantosTag.EXT_X_DATERANGE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[9].tagName! == "#\(PantosTag.EXT_X_DATERANGE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[10].tagName! == "#\(PantosTag.EXT_X_DATERANGE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[11].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[12].tagName, "Tag did not parse properly") // locations do not have tag names + XCTAssert(playlist.tags[13].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[14].tagName, "Tag did not parse properly") // locations do not have tag names + XCTAssert(playlist.tags[15].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[16].tagName, "Tag did not parse properly") // locations do not have tag names + XCTAssert(playlist.tags[17].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[18].tagName, "Tag did not parse properly") // locations do not have tag names + XCTAssert(playlist.tags[19].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[20].tagName, "Tag did not parse properly") // locations do not have tag names + XCTAssert(playlist.tags[21].tagName! == "#\(PantosTag.EXT_X_DATERANGE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[22].tagName! == "#\(PantosTag.EXT_X_DATERANGE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[23].tagName! == "#\(PantosTag.EXT_X_DATERANGE.toString())", "Tag did not parse properly") + XCTAssert(playlist.tags[24].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[25].tagName, "Tag did not parse properly") // locations do not have tag names + XCTAssert(playlist.tags[26].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[27].tagName, "Tag did not parse properly") // locations do not have tag names + XCTAssert(playlist.tags[28].tagName! == "#\(PantosTag.EXTINF.toString())", "Tag did not parse properly") + XCTAssertNil(playlist.tags[29].tagName, "Tag did not parse properly") // locations do not have tag names + + // Check that values are obtained correctly for all EXT-X-DATERANGE tags + // #EXT-X-DATERANGE:ID="3-0x20-1585221432",START-DATE="2020-03-26T11:17:12.17Z",END-DATE="2020-03-26T11:26:25.123Z",SCTE35-IN=0xFC3039000000000000000000050680888462C900230221435545490000000300A00E1270636B5F45503030363739343031303331382104053ABE0441 + XCTAssertEqual(playlist.tags[7].value(forValueIdentifier: PantosValue.id), "3-0x20-1585221432", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[7].value(forValueIdentifier: PantosValue.startDate), "2020-03-26T11:17:12.17Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[7].value(forValueIdentifier: PantosValue.endDate), "2020-03-26T11:26:25.123Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[7].value(forValueIdentifier: PantosValue.scte35In), "0xFC3039000000000000000000050680888462C900230221435545490000000300A00E1270636B5F45503030363739343031303331382104053ABE0441", "Tag did not parse properly") + // #EXT-X-DATERANGE:ID="1-0x22-1585221985",START-DATE="2020-03-26T11:26:25.122Z",PLANNED-DURATION=30.000,SCTE35-OUT=0xFC303E000000000000000000050680888462C900280226435545490000000100E000002932E00E1270636B5F4550303036373934303130333138220404EDA3A9F9 + XCTAssertEqual(playlist.tags[8].value(forValueIdentifier: PantosValue.id), "1-0x22-1585221985", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[8].value(forValueIdentifier: PantosValue.startDate), "2020-03-26T11:26:25.122Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[8].value(forValueIdentifier: PantosValue.plannedDuration), 30.000, "Tag did not parse properly") + XCTAssertEqual(playlist.tags[8].value(forValueIdentifier: PantosValue.scte35Out), "0xFC303E000000000000000000050680888462C900280226435545490000000100E000002932E00E1270636B5F4550303036373934303130333138220404EDA3A9F9", "Tag did not parse properly") + // #EXT-X-DATERANGE:ID="5-0x30-1585221985",START-DATE="2020-03-26T11:26:25.122Z",PLANNED-DURATION=30.000,SCTE35-OUT=0xFC303E000000000000000000050680888462C900280226435545490000000500E000002932E00E1270636B5F455030303637393430313033313830040475A00967 + XCTAssertEqual(playlist.tags[9].value(forValueIdentifier: PantosValue.id), "5-0x30-1585221985", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[9].value(forValueIdentifier: PantosValue.startDate), "2020-03-26T11:26:25.122Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[9].value(forValueIdentifier: PantosValue.plannedDuration), 30.000, "Tag did not parse properly") + XCTAssertEqual(playlist.tags[9].value(forValueIdentifier: PantosValue.scte35Out), "0xFC303E000000000000000000050680888462C900280226435545490000000500E000002932E00E1270636B5F455030303637393430313033313830040475A00967", "Tag did not parse properly") + // #EXT-X-DATERANGE:ID="2-0x10-1585219520",START-DATE="2020-03-26T10:45:20.894Z",PLANNED-DURATION=2713.000,SCTE35-OUT=0xFC303E0000000000000000000506807B4C487A00280226435545490000000200E0000E8DBD100E1270636B5F45503030363739343031303331381001018E5BFFD0 + XCTAssertEqual(playlist.tags[10].value(forValueIdentifier: PantosValue.id), "2-0x10-1585219520", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[10].value(forValueIdentifier: PantosValue.startDate), "2020-03-26T10:45:20.894Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[10].value(forValueIdentifier: PantosValue.plannedDuration), 2713.000, "Tag did not parse properly") + XCTAssertEqual(playlist.tags[10].value(forValueIdentifier: PantosValue.scte35Out), "0xFC303E0000000000000000000506807B4C487A00280226435545490000000200E0000E8DBD100E1270636B5F45503030363739343031303331381001018E5BFFD0", "Tag did not parse properly") + // #EXT-X-DATERANGE:ID="1-0x22-1585221985",START-DATE="2020-03-26T11:26:25.122Z",END-DATE="2020-03-26T11:26:55.119Z",SCTE35-IN=0xFC303900000000000000000005068088AD947A00230221435545490000000100A00E1270636B5F455030303637393430313033313823040432668403 + XCTAssertEqual(playlist.tags[21].value(forValueIdentifier: PantosValue.id), "1-0x22-1585221985", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[21].value(forValueIdentifier: PantosValue.startDate), "2020-03-26T11:26:25.122Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[21].value(forValueIdentifier: PantosValue.endDate), "2020-03-26T11:26:55.119Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[21].value(forValueIdentifier: PantosValue.scte35In), "0xFC303900000000000000000005068088AD947A00230221435545490000000100A00E1270636B5F455030303637393430313033313823040432668403", "Tag did not parse properly") + // #EXT-X-DATERANGE:ID="5-0x30-1585221985",START-DATE="2020-03-26T11:26:25.122Z",END-DATE="2020-03-26T11:26:55.119Z",SCTE35-IN=0xFC303900000000000000000005068088AD947A00230221435545490000000500A00E1270636B5F4550303036373934303130333138310404A150BE8C + XCTAssertEqual(playlist.tags[22].value(forValueIdentifier: PantosValue.id), "5-0x30-1585221985", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[22].value(forValueIdentifier: PantosValue.startDate), "2020-03-26T11:26:25.122Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[22].value(forValueIdentifier: PantosValue.endDate), "2020-03-26T11:26:55.119Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[22].value(forValueIdentifier: PantosValue.scte35In), "0xFC303900000000000000000005068088AD947A00230221435545490000000500A00E1270636B5F4550303036373934303130333138310404A150BE8C", "Tag did not parse properly") + // #EXT-X-DATERANGE:ID="3-0x20-1585222015",START-DATE="2020-03-26T11:26:55.119Z",PLANNED-DURATION=218.000,SCTE35-OUT=0xFC303E00000000000000000005068088AD947A00280226435545490000000300E000012B60A00E1270636B5F45503030363739343031303331382005058B0ADF75 + XCTAssertEqual(playlist.tags[23].value(forValueIdentifier: PantosValue.id), "3-0x20-1585222015", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[23].value(forValueIdentifier: PantosValue.startDate), "2020-03-26T11:26:55.119Z", "Tag did not parse properly") + XCTAssertEqual(playlist.tags[23].value(forValueIdentifier: PantosValue.plannedDuration), 218.000, "Tag did not parse properly") + XCTAssertEqual(playlist.tags[23].value(forValueIdentifier: PantosValue.scte35Out), "0xFC303E00000000000000000005068088AD947A00280226435545490000000300E000012B60A00E1270636B5F45503030363739343031303331382005058B0ADF75", "Tag did not parse properly") + + let validationIssues = PlaylistValidator.validate(variantPlaylist: playlist) + XCTAssert(validationIssues.count == 0, "Should be no issues in the HLS fixtures") + } + func testHLS_UnknownTagArray() { let unknownTag = "EXT-MADE_UP_TAG_FOR_TEST" diff --git a/mambaTests/Fixtures/hls_variant_playlist_with_daterange_metadata.m3u8 b/mambaTests/Fixtures/hls_variant_playlist_with_daterange_metadata.m3u8 new file mode 100644 index 0000000..dfa8e0b --- /dev/null +++ b/mambaTests/Fixtures/hls_variant_playlist_with_daterange_metadata.m3u8 @@ -0,0 +1,31 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:112000 +#EXT-X-DISCONTINUITY-SEQUENCE:5 +#EXT-X-PROGRAM-DATE-TIME:2020-03-26T11:26:25.123Z +#EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.apple.streamingkeydelivery",URI="skd://00066304-0081-fbda-36b0-370048807be3",KEYFORMATVERSIONS="1" +#EXT-X-MAP:URI="1584552452/576p-30fps-1850k-init.mp4" +#EXT-X-DATERANGE:ID="3-0x20-1585221432",START-DATE="2020-03-26T11:17:12.17Z",END-DATE="2020-03-26T11:26:25.123Z",SCTE35-IN=0xFC3039000000000000000000050680888462C900230221435545490000000300A00E1270636B5F45503030363739343031303331382104053ABE0441 +#EXT-X-DATERANGE:ID="1-0x22-1585221985",START-DATE="2020-03-26T11:26:25.122Z",PLANNED-DURATION=30.000,SCTE35-OUT=0xFC303E000000000000000000050680888462C900280226435545490000000100E000002932E00E1270636B5F4550303036373934303130333138220404EDA3A9F9 +#EXT-X-DATERANGE:ID="5-0x30-1585221985",START-DATE="2020-03-26T11:26:25.122Z",PLANNED-DURATION=30.000,SCTE35-OUT=0xFC303E000000000000000000050680888462C900280226435545490000000500E000002932E00E1270636B5F455030303637393430313033313830040475A00967 +#EXT-X-DATERANGE:ID="2-0x10-1585219520",START-DATE="2020-03-26T10:45:20.894Z",PLANNED-DURATION=2713.000,SCTE35-OUT=0xFC303E0000000000000000000506807B4C487A00280226435545490000000200E0000E8DBD100E1270636B5F45503030363739343031303331381001018E5BFFD0 +#EXTINF:6.006,LTC=2020-03-26T11:26:25.123Z +1584552452/576p-30fps-1850k-112001.mp4 +#EXTINF:6.006,LTC=2020-03-26T11:26:31.129Z +1584552452/576p-30fps-1850k-112002.mp4 +#EXTINF:6.006,LTC=2020-03-26T11:26:37.135Z +1584552452/576p-30fps-1850k-112003.mp4 +#EXTINF:6.006,LTC=2020-03-26T11:26:43.141Z +1584552452/576p-30fps-1850k-112004.mp4 +#EXTINF:5.972,LTC=2020-03-26T11:26:49.147Z +1584552452/576p-30fps-1850k-112005.mp4 +#EXT-X-DATERANGE:ID="1-0x22-1585221985",START-DATE="2020-03-26T11:26:25.122Z",END-DATE="2020-03-26T11:26:55.119Z",SCTE35-IN=0xFC303900000000000000000005068088AD947A00230221435545490000000100A00E1270636B5F455030303637393430313033313823040432668403 +#EXT-X-DATERANGE:ID="5-0x30-1585221985",START-DATE="2020-03-26T11:26:25.122Z",END-DATE="2020-03-26T11:26:55.119Z",SCTE35-IN=0xFC303900000000000000000005068088AD947A00230221435545490000000500A00E1270636B5F4550303036373934303130333138310404A150BE8C +#EXT-X-DATERANGE:ID="3-0x20-1585222015",START-DATE="2020-03-26T11:26:55.119Z",PLANNED-DURATION=218.000,SCTE35-OUT=0xFC303E00000000000000000005068088AD947A00280226435545490000000300E000012B60A00E1270636B5F45503030363739343031303331382005058B0ADF75 +#EXTINF:6.006,LTC=2020-03-26T11:26:55.119Z +1584552452/576p-30fps-1850k-112006.mp4 +#EXTINF:6.006,LTC=2020-03-26T11:27:01.125Z +1584552452/576p-30fps-1850k-112007.mp4 +#EXTINF:6.006,LTC=2020-03-26T11:27:07.131Z +1584552452/576p-30fps-1850k-112008.mp4 diff --git a/mambaTests/PantosTagTests.swift b/mambaTests/PantosTagTests.swift index d666283..be5450d 100644 --- a/mambaTests/PantosTagTests.swift +++ b/mambaTests/PantosTagTests.swift @@ -42,6 +42,7 @@ class PantosTagTests: XCTestCase { runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_I_FRAME_STREAM_INF) runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_ENDLIST) runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_BITRATE) + runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_DATERANGE) runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_INDEPENDENT_SEGMENTS) runStringRefLookupTest(onPantosDescriptor: PantosTag.EXT_X_START) @@ -98,6 +99,8 @@ class PantosTagTests: XCTestCase { case .EXT_X_BITRATE: fallthrough case .EXTINF: + fallthrough + case .EXT_X_DATERANGE: 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/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift b/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift index 1418464..b6ac795 100644 --- a/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift +++ b/mambaTests/Tag Validator Tests/GenericDictionaryTagValidatorTests.swift @@ -601,4 +601,200 @@ class GenericDictionaryTagValidatorTests: XCTestCase { mandatory: mandatory, badValues: badValues) } + + /* + The EXT-X-DATERANGE tag associates a Date Range (i.e., a range of + time defined by a starting and ending date) with a set of attribute/ + value pairs. Its format is: + + #EXT-X-DATERANGE: + + where the defined attributes are: + + ID + + A quoted-string that uniquely identifies a Date Range in the + Playlist. This attribute is REQUIRED. + + CLASS + + A client-defined quoted-string that specifies some set of + attributes and their associated value semantics. All Date Ranges + with the same CLASS attribute value MUST adhere to these + semantics. This attribute is OPTIONAL. + + START-DATE + + A quoted-string containing the [ISO_8601] date/time at which the + Date Range begins. This attribute is REQUIRED. + + END-DATE + + A quoted-string containing the [ISO_8601] date/time at which the + Date Range ends. It MUST be equal to or later than the value of + the START-DATE attribute. This attribute is OPTIONAL. + + DURATION + + The duration of the Date Range expressed as a decimal-floating- + point number of seconds. It MUST NOT be negative. A single + instant in time (e.g., crossing a finish line) SHOULD be + represented with a duration of 0. This attribute is OPTIONAL. + + PLANNED-DURATION + The expected duration of the Date Range expressed as a decimal- + floating-point number of seconds. It MUST NOT be negative. This + attribute SHOULD be used to indicate the expected duration of a + Date Range whose actual duration is not yet known. It is + OPTIONAL. + + X- + + The "X-" prefix defines a namespace reserved for client-defined + attributes. The client-attribute MUST be a legal AttributeName. + Clients SHOULD use a reverse-DNS syntax when defining their own + attribute names to avoid collisions. The attribute value MUST be + a quoted-string, a hexadecimal-sequence, or a decimal-floating- + point. An example of a client-defined attribute is X-COM-EXAMPLE- + AD-ID="XYZ123". These attributes are OPTIONAL. + + SCTE35-CMD, SCTE35-OUT, SCTE35-IN + + Used to carry SCTE-35 data; see Section 4.4.5.1.1 for more + information. These attributes are OPTIONAL. + + END-ON-NEXT + + An enumerated-string whose value MUST be YES. This attribute + indicates that the end of the range containing it is equal to the + START-DATE of its Following Range. The Following Range is the + Date Range of the same CLASS that has the earliest START-DATE + after the START-DATE of the range in question. This attribute is + OPTIONAL. + + An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST have a + CLASS attribute. Other EXT-X-DATERANGE tags with the same CLASS + attribute MUST NOT specify Date Ranges that overlap. + + An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST NOT + contain DURATION or END-DATE attributes. + + A Date Range with neither a DURATION, an END-DATE, nor an END-ON- + NEXT=YES attribute has an unknown duration, even if it has a PLANNED- + DURATION. + + If a Playlist contains an EXT-X-DATERANGE tag, it MUST also contain + at least one EXT-X-PROGRAM-DATE-TIME tag. + + If a Playlist contains two EXT-X-DATERANGE tags with the same ID + attribute value, then any AttributeName that appears in both tags + MUST have the same AttributeValue. + + If a Date Range contains both a DURATION attribute and an END-DATE + attribute, the value of the END-DATE attribute MUST be equal to the + value of the START-DATE attribute plus the value of the DURATION + attribute. + + Clients SHOULD ignore EXT-X-DATERANGE tags with illegal syntax. + */ + func test_EXT_X_DATERANGE() { + let tagData = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",PLANNED-DURATION=2713.000,SCTE35-OUT=0xFC303E0000000000000000000506807B4C487A00280226435545490000000200E0000E8DBD100E1270636B5F45503030363739343031303331381001018E5BFFD0" + let optional: [PantosValue] = [.classAttribute, + .endDate, + .duration, + .plannedDuration, + .scte35Cmd, + .scte35Out, + .scte35In, + .endOnNext] + let mandatory: [PantosValue] = [.id, + .startDate] + let badValues: [PantosValue] = [.startDate, + .endDate, + .duration, + .plannedDuration, + .endOnNext] + + validate(tag: PantosTag.EXT_X_DATERANGE, + tagData: tagData, + optional: optional, + mandatory: mandatory, + badValues: badValues) + + // END-ON-NEXT = An enumerated-string whose value MUST be YES. + var data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",END-ON-NEXT=NO,CLASS=\"my:scheme\"" + var validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGEEND_ON_NEXTValueMustBeYES, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + + // An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST have a CLASS attribute. + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",END-ON-NEXT=YES" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustHaveCLASSAttribute, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + + // An EXT-X-DATERANGE tag with an END-ON-NEXT=YES attribute MUST NOT contain DURATION or END-DATE attributes. + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",END-ON-NEXT=YES,CLASS=\"my:scheme\",DURATION=30.000" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainDURATION, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",END-ON-NEXT=YES,CLASS=\"my:scheme\",END-DATE=\"2020-03-28T14:43:16.249Z\"" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainEND_DATE, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + + // If a Date Range contains both a DURATION attribute and an END-DATE attribute, the value of the END-DATE attribute MUST be equal to the + // value of the START-DATE attribute plus the value of the DURATION attribute. + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",DURATION=30.000,END-DATE=\"2020-03-26T10:46:20.000Z\"" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGEValidatorDURATIONAndEND_DATEMustMatchWithSTART_DATE, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",DURATION=30.000,END-DATE=\"2020-03-26T10:45:50.894Z\"" + validationIssues = [] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + + // END-DATE MUST be equal to or later than the value of the START-DATE attribute. + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",END-DATE=\"2020-03-26T10:45:20.000Z\"" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGETagEND_DATEMustBeAfterSTART_DATE, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + + // DURATION MUST NOT be negative. + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",DURATION=-10.000" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGETagDURATIONMustNotBeNegative, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + + // PLANNED-DURATION MUST NOT be negative. + data = "ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-26T10:45:20.894Z\",PLANNED-DURATION=-10.000" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGETagPLANNED_DURATIONMustNotBeNegative, severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + + // Testing a combination of issues are also possible + data = "START-DATE=\"2020-03-26T10:45:20.894Z\",END-ON-NEXT=NO,DURATION=30.000,END-DATE=\"2020-03-26T10:46:20.000Z\"" + validationIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGEEND_ON_NEXTValueMustBeYES, severity: .warning), + PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustHaveCLASSAttribute, severity: .warning), + PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainDURATION, severity: .warning), + PlaylistValidationIssue(description: .EXT_X_DATERANGETagWithEND_ON_NEXTMustNotContainEND_DATE, severity: .warning), + PlaylistValidationIssue(description: .EXT_X_DATERANGEValidatorDURATIONAndEND_DATEMustMatchWithSTART_DATE, severity: .warning), + PlaylistValidationIssue(description: "EXT-X-DATERANGE mandatory value id is missing.", severity: .warning)] + validateEXT_X_DATERANGE(tagData: data, expectedValidationIssues: validationIssues) + } + + private func validateEXT_X_DATERANGE(tagData: String, expectedValidationIssues: [PlaylistValidationIssue]) { + let expectedIssuesDescriptions = expectedValidationIssues.map { $0.description }.joined(separator: "\n") + let (validator, tag) = constructDictionaryValidator(PantosTag.EXT_X_DATERANGE, data: tagData) + guard let errors = validator.validate(tag: tag) else { + if expectedValidationIssues.isEmpty { + return // no issues as expected + } + return XCTFail("Expected EXT-X-DATERANGE validation issue\nTag data: \(tagData)\nExpected issues:\n\(expectedIssuesDescriptions)") + } + let actualIssuesDescriptions = errors.map { $0.description }.joined(separator: "\n") + XCTAssertEqual(errors.count, + expectedValidationIssues.count, + "Mismatch in expected issues and actual issues in EXT_X_DATERANGE validation.\nExpected issues:\n\(expectedIssuesDescriptions)\nActual issues:\n\(actualIssuesDescriptions)") + expectedValidationIssues.forEach { expectedValidationIssue in + guard let matchingIssue = errors.first(where: { $0.description == expectedValidationIssue.description }) else { + return XCTFail("Expected issue \"\(expectedValidationIssue.description)\" not found for EXT-X-DATERANGE tag: \(tagData)\nIssues found:\n\(actualIssuesDescriptions)") + } + XCTAssertEqual(expectedValidationIssue.description, matchingIssue.description) + XCTAssertEqual(expectedValidationIssue.severity, + matchingIssue.severity, + "Expected EXT-X-DATERANGE validation issue (\(expectedValidationIssue.description)) had unexpected severity (\(matchingIssue.severity))") + } + } } diff --git a/mambaTests/ValidatorTests.swift b/mambaTests/ValidatorTests.swift index f418df5..a0462af 100644 --- a/mambaTests/ValidatorTests.swift +++ b/mambaTests/ValidatorTests.swift @@ -126,7 +126,8 @@ class ValidatorTests: XCTestCase { } } - private func validate(validator: VariantPlaylistValidator.Type, playlist: String, expected: Int) { + @discardableResult + private func validate(validator: VariantPlaylistValidator.Type, playlist: String, expected: Int) -> [PlaylistValidationIssue] { let playlist = parseVariantPlaylist(inString: playlist) let validationIssues = validator.validate(variantPlaylist: playlist) @@ -134,6 +135,21 @@ class ValidatorTests: XCTestCase { if validationIssues.count != expected { XCTAssert(false, "Found unexpected validation Issues should have \(expected) actually has \(validationIssues.count)") } + + return validationIssues + } + + private func validate(validator: VariantPlaylistValidator.Type, playlist: String, expectedIssues: [PlaylistValidationIssue]) { + let issues = validate(validator: validator, playlist: playlist, expected: expectedIssues.count) + expectedIssues.forEach { expectedIssue in + guard let matchingIssue = issues.first(where: { $0.description == expectedIssue.description }) else { + return XCTFail("Expected issue \"\(expectedIssue.description)\" not found in variant playlist.\nIssues found:\n\(issues)") + } + XCTAssertEqual(expectedIssue.description, matchingIssue.description) + XCTAssertEqual(expectedIssue.severity, + matchingIssue.severity, + "Expected validation issue (\(expectedIssue.description)) had unexpected severity (\(matchingIssue.severity))") + } } private func validate(validator: MasterPlaylistValidator.Type, playlist: String, expected: Int) { @@ -704,6 +720,76 @@ frag1.ts validate(validator: u, playlist: hlsLoadString, expected: 1) } + let daterangePlaylist = ["#EXTM3U\n", + "#EXT-X-VERSION:6\n", + "#EXT-X-TARGETDURATION:11\n", + "#EXT-X-MEDIA-SEQUENCE:0\n", + "#EXT-X-PROGRAM-DATE-TIME:2020-03-28T18:06:24.492Z\n", + "#EXT-X-DATERANGE:ID=\"2-0x10-1585219520\",START-DATE=\"2020-03-28T17:28:44.901Z\"\n", + "#EXTINF:9.9766,1\n", + "main-01.ts\n", + "#EXTINF:9.1,2\n", + "main-02.ts\n", + "#EXTINF:10,3\n", + "main-03.ts\n", + "#EXTINF:9,4\n", + "main-04.ts\n", + "#EXTINF:10,5\n", + "main-05.ts\n", + "#EXTINF:9.5,6\n", + "main-06.ts\n", + "#EXTINF:9.3,6\n", + "main-07.ts\n"] + + func testEXT_X_DATERANGEPlaylistValidator_OK() { + + let u = EXT_X_DATERANGEPlaylistValidator.self + let hlsLoadString = daterangePlaylist.joined() + validate(validator: u, playlist: hlsLoadString, expected: 0) + } + + func testEXT_X_DATERANGEPlaylistValidator_MissingEXT_X_PROGRAM_DATE_TIME() { + + let u = EXT_X_DATERANGEPlaylistValidator.self + var daterangePlaylist = self.daterangePlaylist + // remove EXT-X-PROGRAM-DATE-TIME + daterangePlaylist.remove(at: 4) + let hlsLoadString = daterangePlaylist.joined() + let expectedIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGEExistsWithNoEXT_X_PROGRAM_DATE_TIME, severity: .warning)] + validate(validator: u, playlist: hlsLoadString, expectedIssues: expectedIssues) + } + + func testEXT_X_DATERANGEPlaylistValidator_MultipleTagsWithSameID_OK() { + + let u = EXT_X_DATERANGEPlaylistValidator.self + var daterangePlaylist = self.daterangePlaylist + + let EXT_X_DATERANGE_1 = "#EXT-X-DATERANGE:ID=\"5-0x30-1585419030\",START-DATE=\"2020-03-28T18:10:30.771Z\",PLANNED-DURATION=30.000\n" + let EXT_X_DATERANGE_2 = "#EXT-X-DATERANGE:ID=\"5-0x30-1585419030\",START-DATE=\"2020-03-28T18:10:30.771Z\",END-DATE=\"2020-03-28T18:11:00.768Z\"\n" + + daterangePlaylist.insert(EXT_X_DATERANGE_2, at: 14) + daterangePlaylist.insert(EXT_X_DATERANGE_1, at: 8) + + let hlsLoadString = daterangePlaylist.joined() + validate(validator: u, playlist: hlsLoadString, expected: 0) + } + + func testEXT_X_DATERANGEPlaylistValidator_MultipleTagsWithSameID_MismatchStartDate() { + + let u = EXT_X_DATERANGEPlaylistValidator.self + var daterangePlaylist = self.daterangePlaylist + + let EXT_X_DATERANGE_1 = "#EXT-X-DATERANGE:ID=\"5-0x30-1585419030\",START-DATE=\"2020-03-28T18:10:30.771Z\",PLANNED-DURATION=30.000\n" + let EXT_X_DATERANGE_2 = "#EXT-X-DATERANGE:ID=\"5-0x30-1585419030\",START-DATE=\"2020-03-28T18:10:20.771Z\",END-DATE=\"2020-03-28T18:11:00.768Z\"\n" + + daterangePlaylist.insert(EXT_X_DATERANGE_2, at: 14) + daterangePlaylist.insert(EXT_X_DATERANGE_1, at: 8) + + let hlsLoadString = daterangePlaylist.joined() + let expectedIssues = [PlaylistValidationIssue(description: .EXT_X_DATERANGEAttributeMismatchForTagsWithSameID, severity: .warning)] + validate(validator: u, playlist: hlsLoadString, expectedIssues: expectedIssues) + } + } private let masterStreamInf = "#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=100,CODECS=\"avc1\",RESOLUTION=10x10\n"