├── .gitignore ├── .swiftlint.yml ├── Tests ├── LinuxMain.swift ├── StripTests │ └── playlist.m3u8 ├── ParsingTests │ ├── Test Utilities │ │ └── FakeOutputStream.swift │ ├── TagTests.swift │ ├── PlaylistParsingTests.swift │ └── AttributeTests.swift ├── TypesTests │ ├── StringTests.swift │ ├── URLTests.swift │ └── HLSCoreTests.swift └── SerializationTests │ ├── MultilineMatching.swift │ ├── SerializationTests.swift │ └── AutoEncoding.swift ├── .github └── workflows │ └── swift.yml ├── Sources ├── Types │ ├── Language.swift │ ├── AttributeTypes.swift │ ├── EncryptionKey.swift │ ├── Logging.swift │ ├── Playlist.swift │ ├── URL.swift │ └── HLSCore.swift ├── Parsing │ ├── CharacterSet.swift │ ├── TypeParsers.swift │ ├── AttributeValueParsers.swift │ ├── MasterPlaylistParser.swift │ ├── AttributeList.swift │ ├── Tags.swift │ ├── MediaPlaylistParser.swift │ └── CoreInitializers.swift └── Serialization │ └── Serializable.swift ├── Readme.md ├── Package.swift └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | 6 | 7 | build 8 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - empty_enum_arguments 3 | - identifier_name 4 | - todo 5 | - nesting 6 | excluded: 7 | - .build 8 | - Tests 9 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import HLSCoreTestSuite 3 | 4 | XCTMain([ 5 | testCase( 6 | HLSCoreTests.allTests + 7 | RenditionGroupTests.allTests 8 | ) 9 | ]) 10 | -------------------------------------------------------------------------------- /Tests/StripTests/playlist.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-TARGET-DURATION:3 3 | #EXT-X-VERSION:3 4 | #EXT-X-PLAYLIST-TYPE:VOD 5 | #EXTINF:3.1 6 | s1.ts 7 | #EXTINF:3.0 8 | s2.ts 9 | #EXTINF:3.2 10 | ../s3.ts 11 | #EXTINF:2.9 12 | alt/s4.ts 13 | #EXT-X-KEY:METHOD=AES-128,URI="ex.key" 14 | #EXTINF:3.0 15 | s5.ts 16 | #EXTINF:3.2 17 | s6.ts 18 | -------------------------------------------------------------------------------- /Tests/ParsingTests/Test Utilities/FakeOutputStream.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FakeOutputStream.swift 3 | // FFCLog 4 | // 5 | // Created by Fabián Cañas on 2/21/19. 6 | // 7 | 8 | import Types 9 | 10 | class FakeOutputStream: LogOutputStream { 11 | 12 | var logs: [String] = [] 13 | 14 | func write(_ string: String) { 15 | logs.append(string) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Lint 17 | run: swiftlint 18 | - name: Build 19 | run: swift build -v 20 | - name: Run tests 21 | run: swift test -v 22 | -------------------------------------------------------------------------------- /Sources/Types/Language.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Language.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/27/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | public struct Language: Equatable { 10 | 11 | let value: String 12 | 13 | public init(_ string: String) { 14 | value = string 15 | } 16 | } 17 | 18 | public extension Language { 19 | static let de = Language("de") 20 | static let en = Language("en") 21 | static let es = Language("es") 22 | static let fr = Language("fr") 23 | static let ja = Language("ja") 24 | static let zh = Language("zh") 25 | } 26 | -------------------------------------------------------------------------------- /Tests/TypesTests/StringTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringTests.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/21/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Types 11 | 12 | class StringTests: XCTestCase { 13 | 14 | func testdeepestDirectoryPath() { 15 | let f = "/path/with/file" 16 | XCTAssertEqual(f.deepestDirectoryPath(), "/path/with/") 17 | 18 | let d = "/path/without/file/" 19 | XCTAssertEqual(d.deepestDirectoryPath(), "/path/without/file/") 20 | } 21 | 22 | static var allTests: [(String, (StringTests) -> () throws -> Void)] { 23 | return [ 24 | ("testdeepestDirectoryPath", testdeepestDirectoryPath) 25 | ] 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # HLSCore 2 | 3 | A collection of Swift packages for working with [HLS](https://developer.apple.com/streaming/). 4 | 5 | HLSCore is three packages. The organization of the project will change as real tools are 6 | built on HLSCore. 7 | 8 | * *Parsing* — An HLS playlist parser built on [parser combinators](https://github.com/fcanas/FFCParserCombinator) 9 | * *Types* — Defines the core elements of HLS as Swift structs and enums 10 | * *Serialization* — Converts HLS playlists defined in Types into strings 11 | 12 | HLSCore is in experimental development. It does not implement the full HLS specification. 13 | It is built in the service of private tools and the command-line tool, 14 | [scrape](https://github.com/fcanas/scrape). 15 | 16 | ## License 17 | 18 | HLSCore is available under the [MIT license](https://github.com/fcanas/HLSCore/blob/master/LICENSE). 19 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "HLSCore", 6 | platforms: [ 7 | .macOS(.v10_12), 8 | .iOS(.v10), 9 | .tvOS(.v10) 10 | ], 11 | products: [ 12 | .library(name: "HLSCore", type:.static, targets: ["Types", "Serialization", "Parsing"]) 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/fcanas/FFCParserCombinator.git", from: "1.0.0"), 16 | ], 17 | targets: [ 18 | .target(name: "Types"), 19 | .testTarget(name: "TypesTests", 20 | dependencies:["Types"]), 21 | .target(name: "Serialization", 22 | dependencies: ["Types"]), 23 | .testTarget(name: "SerializationTests", 24 | dependencies:["Serialization"]), 25 | .target(name: "Parsing", 26 | dependencies: ["Types", "FFCParserCombinator"]), 27 | .testTarget(name: "ParsingTests", 28 | dependencies:["Parsing"]) 29 | ], 30 | swiftLanguageVersions:[.v5] 31 | ) 32 | -------------------------------------------------------------------------------- /Tests/SerializationTests/MultilineMatching.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultilineMatching.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/14/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | func AssertMatchMultilineString( _ e1: @autoclosure () throws -> String, _ e2: @autoclosure () throws -> String, separator: String, file: StaticString = #file, line: UInt = #line) { 13 | 14 | do { 15 | let e1Components = try e1().components(separatedBy: separator) 16 | let e2Components = try e2().components(separatedBy: separator) 17 | 18 | let z = zip(e1Components, e2Components) 19 | 20 | var fileLine: Int = 0 21 | for (s1, s2) in z { 22 | fileLine += 1 23 | XCTAssertEqual(s1, s2, file: file, line: line) 24 | } 25 | 26 | XCTAssertEqual(e1Components.count, e2Components.count, "Expected \(e1Components.count) lines, but found \(e2Components.count) lines") 27 | 28 | } catch { 29 | XCTFail(file: file, line: line) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Fabian Canas 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | the Software, and to permit persons to whom the Software is furnished to do so, 9 | subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Sources/Types/AttributeTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttributeTypes.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/4/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Resolution: Equatable, CustomStringConvertible { 12 | public let width: UInt 13 | public let height: UInt 14 | public init(width: UInt, height: UInt) { 15 | self.width = width 16 | self.height = height 17 | } 18 | 19 | public var description: String { 20 | return "\(width)x\(height)" 21 | } 22 | } 23 | 24 | public struct HexadecimalSequence: Equatable, CustomStringConvertible { 25 | public let value: UInt 26 | /** 27 | * param string - a hex string, upper or lower case, without preceeding 0x 28 | */ 29 | public init?(string: String) { 30 | guard let v = UInt(string, radix: 16) else { 31 | return nil 32 | } 33 | value = v 34 | } 35 | 36 | public init(value v: UInt) { 37 | value = v 38 | } 39 | 40 | public var description: String { 41 | return "0x" + String(value, radix: 16, uppercase: true) 42 | } 43 | } 44 | 45 | public struct SignedFloat: Equatable, CustomStringConvertible, RawRepresentable { 46 | public let rawValue: Double 47 | public init(rawValue: Double) { 48 | self.rawValue = rawValue 49 | } 50 | 51 | public init?(_ string: String) { 52 | guard let v = Double(string) else { 53 | return nil 54 | } 55 | self.init(rawValue: v) 56 | } 57 | 58 | public var description: String { 59 | return rawValue.description 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/Parsing/CharacterSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CharacterSet.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/4/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension CharacterSet { 12 | /// Characters allowed in an upper-case hex number, not including an X for prefixing 13 | /// 14 | ///`[0...9, A...F]` 15 | static let hexidecimalDigits = CharacterSet.decimalDigits 16 | .union(CharacterSet(charactersIn: "A"..."F")) 17 | 18 | /// Characters allowed in a quoted string. 19 | /// 20 | /// Inverse of `[\r, \n, "]` 21 | static let forQuotedString = CharacterSet(charactersIn: "\r\n\"").inverted 22 | 23 | /// Valid characters in an enumerated string 24 | /// 25 | /// Like a quoted string additionally not allowing whitespace nor commas 26 | static let forEnumeratedString = CharacterSet.whitespacesAndNewlines 27 | .union(CharacterSet(charactersIn: ",\"")) 28 | .inverted 29 | 30 | /// Valid characters in a URL 31 | static let urlAllowed = CharacterSet.urlUserAllowed 32 | .union(.urlHostAllowed) 33 | .union(.urlPathAllowed) 34 | .union(.urlQueryAllowed) 35 | .union(.urlFragmentAllowed) 36 | .union(.urlPasswordAllowed) 37 | .union(CharacterSet(charactersIn: ":")) 38 | 39 | /// Valid characters in an ISO 8601 date 40 | static let iso8601 = CharacterSet.decimalDigits 41 | .union(CharacterSet(charactersIn: "WTZ/+:−-")) 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Types/EncryptionKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EncryptionKey.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/22/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum EncryptionMethod: Equatable { 12 | case None 13 | case AES128(URL) 14 | case SampleAES(URL) 15 | } 16 | 17 | public let IdentityDecryptionKeyFormat = "identity" 18 | 19 | public struct InitializationVector: Equatable { 20 | public let low: UInt64 21 | public let high: UInt64 22 | public init(low: UInt64, high: UInt64) { 23 | self.low = low 24 | self.high = high 25 | } 26 | } 27 | 28 | public struct DecryptionKey: Equatable { 29 | 30 | public let method: EncryptionMethod 31 | public let initializationVector: InitializationVector? 32 | public let keyFormat: String 33 | public let keyFormatVersions: [Int]? 34 | 35 | public init(method: EncryptionMethod, 36 | initializationVector: InitializationVector? = nil, 37 | keyFormat: String = IdentityDecryptionKeyFormat, 38 | keyFormatVersions: [Int]? = nil) { 39 | self.method = method 40 | self.initializationVector = initializationVector 41 | self.keyFormat = keyFormat 42 | self.keyFormatVersions = keyFormatVersions 43 | } 44 | 45 | private init() { 46 | self.method = .None 47 | self.keyFormat = IdentityDecryptionKeyFormat 48 | 49 | keyFormatVersions = nil 50 | initializationVector = nil 51 | } 52 | 53 | public static let None: DecryptionKey = DecryptionKey.init() 54 | 55 | } 56 | 57 | public func setDecryptionKey(_ key: DecryptionKey, forSegments segments: [MediaSegment]) -> [MediaSegment] { 58 | return segments.map { 59 | var segment = $0 60 | segment.decryptionKey = key 61 | return segment 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Parsing/TypeParsers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypeParsers.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/5/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Types 11 | import FFCParserCombinator 12 | 13 | /// Raw Type Parsers 14 | 15 | struct TypeParser { 16 | 17 | static let url = { URL(string: $0) } <^!> ({ String($0) } <^> CharacterSet.urlAllowed.parser().many1) 18 | 19 | static let hex = BasicParser.hexPrefix *> ({ characters in HexadecimalSequence(string: String(characters))! } <^> BasicParser.hexDigit.many1) 20 | 21 | static let float = { Double($0)! } <^> BasicParser.floatingPointString 22 | 23 | static let signedFloat = ({ SignedFloat($0)! } <^> BasicParser.negation.optional.followed(by: BasicParser.floatingPointString) { (neg, num) -> String in 24 | (neg ?? "") + num 25 | }) 26 | 27 | static let quoteString = { QuotedString($0) } <^> BasicParser.quote *> CharacterSet.forQuotedString.parser().many <* BasicParser.quote 28 | 29 | static let enumString = EnumeratedString.init <^> CharacterSet.forEnumeratedString.parser().many1 30 | 31 | static let resolution = Resolution.init <^> UInt.parser <* BasicParser.x <&> UInt.parser 32 | 33 | static let date = dateFromString <^> ({ String($0) } <^> CharacterSet.iso8601.parser().many1 ) 34 | 35 | static let byteRange = { return ($0.1 ?? 0)...(($0.1 ?? 0) + ($0.0 - 1)) } <^> (UInt.parser <&> ("@" *> UInt.parser).optional) 36 | } 37 | 38 | @available(iOS 10.0, OSX 10.12, *) private let dateFormatter = ISO8601DateFormatter() 39 | 40 | private let legacyFormatter: DateFormatter = { 41 | let f = DateFormatter() 42 | f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" 43 | return f 44 | }() 45 | 46 | private func dateFromString(_ string: String) -> Date? { 47 | if #available(iOS 10.0, OSX 10.12, *) { 48 | return dateFormatter.date(from: string) 49 | } 50 | 51 | return legacyFormatter.date(from: string) 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Types/Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logging.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 4/15/18. 6 | // Copyright © 2018 Fabián Cañas. All rights reserved. 7 | // 8 | 9 | import Darwin 10 | import Foundation 11 | 12 | public protocol Logger { 13 | func log(_ string: String, level: HLSLogging.Level?) 14 | } 15 | 16 | public protocol LogOutputStream { 17 | mutating func write(_ string: String) 18 | } 19 | 20 | public enum HLSLogging { 21 | 22 | public enum Level: Comparable { 23 | 24 | public static func < (lhs: Level, rhs: Level) -> Bool { 25 | return lhs.value < rhs.value 26 | } 27 | 28 | case all 29 | case info 30 | case debug 31 | case error 32 | case fatal 33 | case off 34 | 35 | private var value: UInt { 36 | get { 37 | switch self { 38 | 39 | case .all: 40 | return 0 41 | case .info: 42 | return 1 43 | case .debug: 44 | return 2 45 | case .error: 46 | return 3 47 | case .fatal: 48 | return 4 49 | case .off: 50 | return UInt.max 51 | } 52 | } 53 | } 54 | } 55 | 56 | public struct StandardOutput: LogOutputStream { 57 | public init() {} 58 | public func write(_ string: String) { 59 | fputs(string, stdout) 60 | } 61 | } 62 | 63 | public struct StandardError: LogOutputStream { 64 | public init() {} 65 | public func write(_ string: String) { 66 | fputs(string, stderr) 67 | } 68 | } 69 | 70 | public class Default: Logger { 71 | 72 | private let thresholdLevel: Level 73 | private var errorOutput: LogOutputStream 74 | private var infoOutput: LogOutputStream 75 | 76 | public init(thresholdLevel: Level = Level.off, errorOut: LogOutputStream = StandardError(), infoOut: LogOutputStream = StandardOutput()) { 77 | self.thresholdLevel = thresholdLevel 78 | errorOutput = errorOut 79 | infoOutput = infoOut 80 | } 81 | 82 | public func log(_ string: String, level: Level?) { 83 | if (level ?? .info) >= thresholdLevel { 84 | infoOutput.write(string) 85 | } else { 86 | errorOutput.write(string) 87 | } 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /Tests/ParsingTests/TagTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagTests.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/17/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Types 11 | @testable import Parsing 12 | 13 | func entity(fromTag tag: AnyTag) -> Any? { 14 | 15 | switch tag { 16 | case let .playlist(playlist): 17 | switch playlist { 18 | case .version(_): 19 | return nil 20 | case .independentSegments: 21 | return nil 22 | case let .startIndicator(startIndicator): 23 | return startIndicator 24 | case .url(_): 25 | return nil 26 | case .comment(_): 27 | return nil 28 | } 29 | case .media(_): 30 | return nil 31 | case let .segment(segment): 32 | switch segment { 33 | case .inf(_, _): 34 | return nil 35 | case .byteRange(_): 36 | return nil 37 | case .discontinuity: 38 | return nil 39 | case let .key(key): 40 | return key 41 | case .map(_): 42 | return nil 43 | case .programDateTime(_): 44 | return nil 45 | case .dateRange(_): 46 | return nil 47 | } 48 | case .master(_): 49 | return nil 50 | } 51 | 52 | } 53 | 54 | class StartTagParsingTests: XCTestCase { 55 | 56 | func testTimeOffset() { 57 | let startTime: TimeInterval = 1.3 58 | let tag = "#EXT-X-START:TIME-OFFSET=\(startTime)" 59 | let (parsedTag, _) = HLS.Playlist.EXTXSTART.run(tag)! 60 | let startIndicator = entity(fromTag: AnyTag.playlist(parsedTag)) as! StartIndicator 61 | XCTAssertEqual(startIndicator, StartIndicator(at: startTime)) 62 | } 63 | 64 | func testExplicitNotPrecise() { 65 | let startTime: TimeInterval = 1.3 66 | let tag = "#EXT-X-START:TIME-OFFSET=\(startTime),PRECISE=NO" 67 | let (parsedTag, _) = HLS.Playlist.EXTXSTART.run(tag)! 68 | let startIndicator = entity(fromTag: AnyTag.playlist(parsedTag)) as! StartIndicator 69 | XCTAssertEqual(startIndicator, StartIndicator(at: startTime)) 70 | } 71 | 72 | func testExplicitPrecise() { 73 | let startTime: TimeInterval = 1.3 74 | let tag = "#EXT-X-START:TIME-OFFSET=\(startTime),PRECISE=YES" 75 | let (parsedTag, _) = HLS.Playlist.EXTXSTART.run(tag)! 76 | let startIndicator = entity(fromTag: AnyTag.playlist(parsedTag)) as! StartIndicator 77 | XCTAssertEqual(startIndicator, StartIndicator(at: startTime, preciseStart: true)) 78 | } 79 | 80 | static var allTests: [(String, (StartTagParsingTests) -> () throws -> Void)] { 81 | return [ 82 | ("testTimeOffset", testTimeOffset), 83 | ("testExplicitNotPrecise", testExplicitNotPrecise), 84 | ("testExplicitPrecise", testExplicitPrecise) 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/Types/Playlist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Playlist.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/2/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol Playlist { 12 | var version: UInt { get } 13 | var start: StartIndicator? { get } 14 | } 15 | 16 | /// Master Playlist 17 | 18 | public struct MasterPlaylist: Playlist { 19 | public let version: UInt 20 | public let uri: URL 21 | 22 | public let renditions: [Rendition] 23 | public let streams: [StreamInfo] 24 | public let start: StartIndicator? 25 | 26 | public init(version: UInt, uri: URL, streams: [StreamInfo], renditions: [Rendition], start: StartIndicator?) { 27 | self.version = version 28 | self.uri = uri 29 | self.streams = streams 30 | self.renditions = renditions 31 | self.start = start 32 | } 33 | } 34 | 35 | /// Media Playlist 36 | 37 | public struct MediaPlaylist: Playlist, Equatable { 38 | 39 | public enum PlaylistType: String { 40 | case VOD = "VOD" 41 | case Event = "EVENT" 42 | } 43 | 44 | public init(type: PlaylistType?, 45 | version: UInt = 1, 46 | uri: URL, 47 | targetDuration: TimeInterval, 48 | closed: Bool, 49 | start: StartIndicator? = nil, 50 | segments: [MediaSegment], 51 | independentSegments: Bool, 52 | mediaSequence: UInt) { 53 | self.type = type 54 | self.version = version 55 | self.uri = uri 56 | self.targetDuration = targetDuration 57 | self.closed = closed 58 | self.start = start 59 | self.segments = segments.map({ (segment) -> MediaSegment in 60 | MediaSegment(uri: segment.resource.uri.relativeURL(baseURL: uri.directoryURL()), 61 | duration: segment.duration, 62 | title: segment.title, 63 | byteRange: segment.byteRange, 64 | decryptionKey: segment.decryptionKey, 65 | mediaInitializationSection: segment.mediaInitializationSection) 66 | }) 67 | self.independentSegments = independentSegments 68 | self.mediaSequence = mediaSequence 69 | } 70 | 71 | public let type: PlaylistType? 72 | 73 | public let version: UInt 74 | // private var playlist :MasterPlaylist? = nil 75 | 76 | public let uri: URL 77 | 78 | public let targetDuration: TimeInterval 79 | 80 | /// Marks the presence of a `EXT-X-ENDLIST` tag 81 | public let closed: Bool 82 | 83 | public let start: StartIndicator? 84 | 85 | // TODO : a playlist should probably _be_ a sequence of media segments 86 | public let segments: [MediaSegment] 87 | 88 | public let independentSegments: Bool 89 | 90 | /// Media Sequence of the first segment 91 | public let mediaSequence: UInt 92 | } 93 | -------------------------------------------------------------------------------- /Tests/TypesTests/URLTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLTests.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/19/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Types 11 | 12 | class URLTests: XCTestCase { 13 | func testRelativeURL() { 14 | let toRelative = URL(string: "http://example.com/test/thing")! 15 | let base = URL(string: "http://example.com/test/")! 16 | 17 | let newRealtive = toRelative.relativeURL(baseURL: base) 18 | 19 | XCTAssertEqual(newRealtive.relativeString, "thing") 20 | XCTAssertEqual(newRealtive, URL(string: "thing", relativeTo: base)) 21 | } 22 | 23 | func testRelativeURLSchemeMismatch() { 24 | let toRelative = URL(string: "http://example.com/test/thing")! 25 | let base = URL(string: "ftp://example.com/test/")! 26 | 27 | let newRealtive = toRelative.relativeURL(baseURL: base) 28 | 29 | XCTAssertEqual(newRealtive.relativeString, "http://example.com/test/thing") 30 | XCTAssertNotEqual(newRealtive, URL(string: "thing", relativeTo: base)) 31 | } 32 | 33 | func testRelativeURLHostMismatch() { 34 | let toRelative = URL(string: "http://example.com/test/thing")! 35 | let base = URL(string: "http://badhost.example.com/test/")! 36 | 37 | let newRealtive = toRelative.relativeURL(baseURL: base) 38 | 39 | XCTAssertEqual(newRealtive, toRelative) 40 | XCTAssertNotEqual(newRealtive, URL(string: "thing", relativeTo: base)) 41 | } 42 | 43 | func testRelativeURLPortMismatch() { 44 | let toRelative = URL(string: "http://example.com/test/thing")! 45 | let base = URL(string: "http://example.com:80/test/")! 46 | 47 | let newRealtive = toRelative.relativeURL(baseURL: base) 48 | 49 | XCTAssertEqual(newRealtive, toRelative) 50 | XCTAssertNotEqual(newRealtive.relativeString, "thing") 51 | XCTAssertNotEqual(newRealtive, URL(string: "thing", relativeTo: base)) 52 | } 53 | 54 | func testRelativeURLUserMismatch() { 55 | let toRelative = URL(string: "http://example.com/test/thing")! 56 | let base = URL(string: "http://fcanas@example.com/test/")! 57 | 58 | let newRealtive = toRelative.relativeURL(baseURL: base) 59 | 60 | XCTAssertEqual(newRealtive, toRelative) 61 | XCTAssertNotEqual(newRealtive.relativeString, "thing") 62 | XCTAssertNotEqual(newRealtive, URL(string: "thing", relativeTo: base)) 63 | } 64 | 65 | func testRelativeDotDirectories() { 66 | let toRelative = URL(string: "http://example.com/test/thing")! 67 | let base = URL(string: "http://example.com/test/intermediate/")! 68 | 69 | let newRealtive = toRelative.relativeURL(baseURL: base) 70 | 71 | XCTAssertEqual(newRealtive.relativeString, "../thing") 72 | XCTAssertEqual(newRealtive, URL(string: "../thing", relativeTo: base)) 73 | } 74 | 75 | static var allTests: [(String, (URLTests) -> () throws -> Void)] { 76 | return [ 77 | ("testRelativeURL", testRelativeURL) 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Types/URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/14/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension URL { 12 | 13 | /** 14 | * Called on an absolute URL, returns a new relative URL based on the parameter. 15 | * for example: 16 | * (http://example.com/a/test).relativeURL(http://example.com/a/) -> test 17 | * 18 | * [RFC 1808](https://tools.ietf.org/html/rfc1808) is relevant, but not strictly followed. 19 | */ 20 | func relativeURL(baseURL: URL) -> URL { 21 | let baseString = baseURL.absoluteString 22 | let selfString = absoluteString 23 | 24 | if selfString.hasPrefix(baseString) { 25 | var relativeString = selfString 26 | relativeString.removeSubrange(baseString.fullRange) 27 | return URL(string: relativeString, relativeTo: baseURL) ?? self 28 | } else { 29 | 30 | let commonPrefix = selfString.commonPrefix(with: baseString) 31 | 32 | // By checking the scheme, user, host, and port are equal, we avoid 33 | // the problem of creating relative URLs where they're not 34 | // appropriate, and that further checking is effectively on the path 35 | guard baseURL.scheme == self.scheme 36 | && baseURL.user == self.user 37 | && baseURL.host == self.host 38 | && baseURL.port == self.port else { 39 | return self 40 | } 41 | 42 | // TODO : how to handle query and fragments? 43 | 44 | let strippedBaseString = baseString.replacingCharacters(in: commonPrefix.fullRange, with: "") 45 | let strippedSelfString = selfString.replacingCharacters(in: commonPrefix.fullRange, with: "") 46 | 47 | let numberOfDirectorySlashes = strippedBaseString.reduce(0, { (count: Int, character) -> Int in 48 | if character == "/" { 49 | return count + 1 50 | } 51 | return count 52 | }) 53 | 54 | var prefix = "" 55 | for _ in 0.. URL { 68 | var components = URLComponents(url: self, resolvingAgainstBaseURL: false)! 69 | components.path = path.deepestDirectoryPath() 70 | return components.url! 71 | } 72 | } 73 | 74 | extension String { 75 | 76 | func deepestDirectoryPath() -> String { 77 | if self.hasSuffix("/") { 78 | return self 79 | } 80 | guard let lastSlashIndex = self.range(of: "/", options: .backwards)?.lowerBound else { 81 | return "/" 82 | } 83 | 84 | return String(self[.. { 88 | return Range(uncheckedBounds: (lower: startIndex, upper: endIndex)) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Parsing/AttributeValueParsers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttributeValueTypes.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/4/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Types 11 | import FFCParserCombinator 12 | 13 | /// Attributes in Attribute lists can take on a limited number of types. 14 | enum AttributeValue: Equatable { 15 | case decimalInteger(UInt) 16 | case hexadecimalSequence(HexadecimalSequence) 17 | case decimalFloatingPoint(Double) 18 | case signedDecimalFloatingPoint(SignedFloat) 19 | case quotedString(String) 20 | case enumeratedString(EnumeratedString) 21 | case decimalResolution(Resolution) 22 | 23 | init(_ int: UInt) { 24 | self = .decimalInteger(int) 25 | } 26 | 27 | init(_ hex: HexadecimalSequence) { 28 | self = .hexadecimalSequence(hex) 29 | } 30 | 31 | init(_ float: Double) { 32 | self = .decimalFloatingPoint(float) 33 | } 34 | 35 | init(_ signedFloat: SignedFloat) { 36 | self = .signedDecimalFloatingPoint(signedFloat) 37 | } 38 | 39 | init(_ quotedString: String) { 40 | self = .quotedString(quotedString) 41 | } 42 | 43 | init(_ enumeratedString: EnumeratedString) { 44 | self = .enumeratedString(enumeratedString) 45 | } 46 | 47 | init(_ decimalResolution: Resolution) { 48 | self = .decimalResolution(decimalResolution) 49 | } 50 | } 51 | 52 | extension AttributeValue: CustomStringConvertible { 53 | var description: String { 54 | switch self { 55 | case let .decimalInteger(int): 56 | return int.description 57 | case let .hexadecimalSequence(hex): 58 | return hex.description 59 | case let .decimalFloatingPoint(float): 60 | return float.description 61 | case let .signedDecimalFloatingPoint(signedFloat): 62 | return signedFloat.description 63 | case let .quotedString(string): 64 | return string 65 | case let .enumeratedString(enumeratedString): 66 | return enumeratedString.description 67 | case let .decimalResolution(resolution): 68 | return resolution.description 69 | } 70 | } 71 | 72 | } 73 | 74 | typealias QuotedString = String 75 | 76 | struct EnumeratedString: RawRepresentable, Equatable, CustomStringConvertible { 77 | let rawValue: String 78 | 79 | init(_ string: String) { 80 | rawValue = string 81 | } 82 | 83 | init(_ characters: [Character]) { 84 | rawValue = String(characters) 85 | } 86 | 87 | init(rawValue: String) { 88 | self.rawValue = rawValue 89 | } 90 | 91 | var description: String { 92 | return rawValue 93 | } 94 | } 95 | 96 | /// Attribute Value Parsers 97 | 98 | let decimalInteger = AttributeValue.init <^> UInt.parser 99 | 100 | let hexSequence = AttributeValue.init <^> TypeParser.hex 101 | 102 | let decimalFloatingPoint = AttributeValue.init <^> TypeParser.float 103 | 104 | let signedDecimalFloatingPoint = AttributeValue.init <^> TypeParser.signedFloat 105 | 106 | let quotedString = AttributeValue.init <^> TypeParser.quoteString 107 | 108 | let enumeratedString = AttributeValue.init <^> TypeParser.enumString 109 | 110 | let decimalResolution = AttributeValue.init <^> TypeParser.resolution 111 | -------------------------------------------------------------------------------- /Tests/SerializationTests/SerializationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SerializationTests.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/14/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | // Here we test public behavior. We shouldn't import the module as testable 11 | // because visibility is part of our contract. 12 | import Serialization 13 | import Types 14 | 15 | class SerializationTests: XCTestCase { 16 | func testBasicPlaylist() { 17 | let urlString = "http://example.com/test/playlist.m3u8" 18 | 19 | let s1URL = URL(string: "http://example.com/test/s1.ts")! 20 | let s2URL = URL(string: "s2.ts")! 21 | let s3URL = URL(string: "http://example.com/s3.ts")! 22 | let s4URL = URL(string: "http://example.com/test/alt/s4.ts")! 23 | let s5URL = URL(string: "s5.ts")! 24 | let s6URL = URL(string: "s6.ts")! 25 | 26 | let segments = [MediaSegment(uri: s1URL, duration: 3.1), 27 | MediaSegment(uri: s2URL, duration: 3.0), 28 | MediaSegment(uri: s3URL, duration: 3.2), 29 | MediaSegment(uri: s4URL, duration: 2.9)] 30 | 31 | let unencryptedSegments = [MediaSegment(uri: s5URL, duration: 3.0), 32 | MediaSegment(uri: s6URL, duration: 3.2)] 33 | let key = DecryptionKey(method: .AES128(URL(string: "ex.key")!)) 34 | let encryptedSegments = setDecryptionKey(key, forSegments: unencryptedSegments) 35 | 36 | let playlist: MediaPlaylist = MediaPlaylist(type: .VOD, 37 | version: 3, 38 | uri: URL(string: urlString)!, 39 | targetDuration: 3, 40 | closed: true, 41 | start: nil, 42 | segments: segments + encryptedSegments, 43 | independentSegments: false, 44 | mediaSequence: 0) 45 | 46 | let stringResource = MediaPlaylistSerializer().serialize(playlist) 47 | 48 | XCTAssertEqual(stringResource.uri.absoluteString, urlString) 49 | 50 | let expectedPlaylist = 51 | "#EXTM3U" + "\n" + 52 | "#EXT-X-TARGETDURATION:3" + "\n" + 53 | "#EXT-X-VERSION:3" + "\n" + 54 | "#EXT-X-PLAYLIST-TYPE:VOD" + "\n" + 55 | "#EXTINF:3.1," + "\n" + 56 | "s1.ts" + "\n" + 57 | "#EXTINF:3.0," + "\n" + 58 | "s2.ts" + "\n" + 59 | "#EXTINF:3.2," + "\n" + 60 | "../s3.ts" + "\n" + 61 | "#EXTINF:2.9," + "\n" + 62 | "alt/s4.ts" + "\n" + 63 | "#EXT-X-KEY:METHOD=AES-128,URI=\"ex.key\"" + "\n" + 64 | "#EXTINF:3.0," + "\n" + 65 | "s5.ts" + "\n" + 66 | "#EXTINF:3.2," + "\n" + 67 | "s6.ts" + "\n" 68 | 69 | AssertMatchMultilineString(stringResource.value!, expectedPlaylist, separator: "\n") 70 | } 71 | 72 | static var allTests: [(String, (SerializationTests) -> () throws -> Void)] { 73 | return [ 74 | ("testBasicPlaylist", testBasicPlaylist) 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Parsing/MasterPlaylistParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MasterPlaylistParser.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 10/22/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Types 11 | import FFCParserCombinator 12 | 13 | private struct PlaylistBuilder { 14 | var version: UInt = 1 15 | var streams: [StreamInfo] = [] 16 | var start: StartIndicator? 17 | var independentSegments: Bool = false 18 | var renditions: [Rendition] = [] 19 | let url: URL 20 | 21 | var fatalTag: AnyTag? 22 | init(rootURL: URL) { 23 | url = rootURL 24 | start = nil 25 | } 26 | } 27 | 28 | public func parseMasterPlaylist(string: String, atURL url: URL, logger: Logger = HLSLogging.Default()) -> MasterPlaylist? { 29 | let parser = HLS.Playlist.StartTag *> newlines *> ( HLS.Playlist.Master.TagParser <* newlines ).many 30 | 31 | let parseResult = parser.run(string) 32 | 33 | if let remainingChars = parseResult?.1, (remainingChars.count > 0) { 34 | logger.log("REMAINDER:\n\(String(remainingChars))", level: .error) 35 | } else { 36 | logger.log("NO REMAINDER", level: .info) 37 | } 38 | 39 | guard let tags = parseResult?.0 else { 40 | return nil 41 | } 42 | 43 | let reducer = { (state: PlaylistBuilder, tag: AnyTag) in 44 | reducePlaylistBuilder(state: state, tag: tag, logger: logger) 45 | } 46 | 47 | let playlistBuilder = tags.reduce(PlaylistBuilder(rootURL: url), reducer) 48 | 49 | let streams = playlistBuilder.streams.map { 50 | StreamInfo(bandwidth: $0.bandwidth, 51 | averageBandwidth: $0.averageBandwidth, 52 | codecs: $0.codecs, 53 | resolution: $0.resolution, 54 | frameRate: $0.frameRate, 55 | uri: URL(string: $0.uri.absoluteString, relativeTo: url )!) 56 | } 57 | 58 | let renditions = playlistBuilder.renditions.map { 59 | Rendition(mediaType: $0.type, 60 | uri: $0.uri.flatMap({URL(string: $0.absoluteString, relativeTo: url)}), 61 | groupID: $0.groupID, 62 | language: $0.language, 63 | associatedLanguage: $0.associatedLanguage, 64 | name: $0.name, 65 | defaultRendition: $0.defaultRendition, 66 | forced: $0.forced) 67 | } 68 | 69 | return MasterPlaylist(version: playlistBuilder.version, 70 | uri: url, streams: streams, 71 | renditions: renditions, 72 | start: playlistBuilder.start) 73 | } 74 | 75 | private func reducePlaylistBuilder(state: PlaylistBuilder, tag: AnyTag, logger: Logger) -> PlaylistBuilder { 76 | var returnState = state 77 | 78 | switch tag { 79 | case let .playlist(playlistTag): 80 | switch playlistTag { 81 | case let .version(versionNumber): 82 | returnState.version = versionNumber 83 | case .independentSegments: 84 | returnState.independentSegments = true 85 | case let .startIndicator(start): 86 | returnState.start = start 87 | case .url(_): 88 | // Playlist URLs are handled by the #EXT-X-STREAM-INF tag since 89 | // they're required to be sequential 90 | // TODO: Move URL tag into the media or segment type? 91 | returnState.fatalTag = tag 92 | case .comment(_): 93 | break 94 | } 95 | case .media(_): 96 | returnState.fatalTag = tag 97 | case .segment(_): 98 | returnState.fatalTag = tag 99 | case let .master(masterTag): 100 | switch masterTag { 101 | case let .media(rendition): 102 | returnState.renditions.append(rendition) 103 | case let .streamInfo(streamInfo): 104 | returnState.streams.append(streamInfo) 105 | case let .iFramesStreamInfo(attributes): 106 | // TODO: iFrame Stream Info 107 | logger.log("Unprocessed iFrame Stream Info :: \(attributes)", level: .error) 108 | case let .sessionData(attributes): 109 | // TODO: Session Data 110 | logger.log("Unprocessed Session Data :: \(attributes)", level: .error) 111 | case let .sessionKey(attributes): 112 | // TODO: Session Key 113 | logger.log("Unprocessed Session Key :: \(attributes)", level: .error) 114 | } 115 | } 116 | 117 | return returnState 118 | } 119 | -------------------------------------------------------------------------------- /Sources/Serialization/Serializable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Serializable.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/14/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Types 11 | 12 | public struct Resource { 13 | public let uri: URL 14 | public let value: T? 15 | } 16 | 17 | public typealias StringResource = Resource 18 | 19 | protocol Serializer { 20 | associatedtype SerializationFormat 21 | associatedtype Input 22 | func serialize(_: Input) -> SerializationFormat 23 | } 24 | 25 | enum LineEnding: String { 26 | case CR = "\n" 27 | case CRLF = "\r\n" 28 | } 29 | 30 | extension String { 31 | func _append(_ addendum: String, line: LineEnding) -> String { 32 | return self + line.rawValue + addendum 33 | } 34 | } 35 | 36 | public struct MediaPlaylistSerializer: Serializer { 37 | 38 | let usesRelativeURI: Bool = true 39 | 40 | let newline: LineEnding = .CR 41 | 42 | typealias SerializationFormat = StringResource 43 | typealias Input = MediaPlaylist 44 | 45 | public func serialize(_ playlist: MediaPlaylist) -> StringResource { 46 | var output = "#EXTM3U" // required first line 47 | 48 | output = output._append("#EXT-X-TARGETDURATION:\(Int(playlist.targetDuration))", line: newline) 49 | 50 | if playlist.version > 1 { 51 | output = output._append("#EXT-X-VERSION:\(Int(playlist.version))", line: newline) 52 | } 53 | 54 | if playlist.mediaSequence != 0 { 55 | output = output._append("#EXT-X-MEDIA-SEQUENCE:\(playlist.mediaSequence)", line: newline) 56 | } 57 | 58 | if let type = playlist.type { 59 | output = output._append("#EXT-X-PLAYLIST-TYPE:\(type)", line: newline) 60 | } 61 | 62 | var activeDecryptionKey: DecryptionKey? 63 | 64 | var lastOutputMediaInitialization: MediaInitializationSection? 65 | 66 | if playlist.independentSegments { 67 | output = output._append("#EXT-X-INDEPENDENT-SEGMENTS", line: newline) 68 | } 69 | 70 | for segment in playlist.segments { 71 | 72 | if let smi = segment.mediaInitializationSection, smi != lastOutputMediaInitialization { 73 | var tagString = "#EXT-X-MAP:URI=\"\(smi.uri.relativeString)\"" 74 | if let byteRange = smi.byteRange { 75 | tagString += ",BYTERANGE=\"\(byteRange.distance(from: byteRange.startIndex, to: byteRange.endIndex))@\(byteRange.first!)\"" 76 | } 77 | output = output._append(tagString, line: newline) 78 | lastOutputMediaInitialization = smi 79 | } 80 | 81 | if segment.decryptionKey != activeDecryptionKey { 82 | activeDecryptionKey = segment.decryptionKey 83 | if let key = activeDecryptionKey { 84 | output = output._append(key.playlistString, line: newline) 85 | } else { 86 | output = output._append(DecryptionKey.None.playlistString, line: newline) 87 | } 88 | } 89 | output = output._append("#EXTINF:\(segment.duration),", line: newline) 90 | if let byteRange = segment.byteRange, let start = byteRange.first { 91 | let length = byteRange.distance(from: byteRange.startIndex, to: byteRange.endIndex) 92 | output = output._append("#EXT-X-BYTERANGE:\(length)@\(start)", line: newline) 93 | } 94 | output = output._append(segment.resource.uri.relativeString, line: newline) 95 | } 96 | 97 | output += "\n" 98 | 99 | return Resource(uri: playlist.uri, value: output) 100 | } 101 | 102 | public init() {} 103 | } 104 | 105 | private extension DecryptionKey { 106 | 107 | var playlistString: String { 108 | 109 | var out = "#EXT-X-KEY:METHOD=" 110 | 111 | switch method { 112 | case .None: 113 | out += "NONE" 114 | return out 115 | case let .AES128(uri): 116 | out += "AES-128,URI=\"\(uri)\"" 117 | case let .SampleAES(uri): 118 | out += "SAMPLE-AES,URI=\"\(uri)\"" 119 | } 120 | 121 | if let iv = initializationVector { 122 | out += String(format: ",%8x%8x", iv.high, iv.low) 123 | } 124 | if keyFormat != "identity" { 125 | out += "," + keyFormat 126 | } 127 | if let v = keyFormatVersions { 128 | out += "," + v.map({ String($0) }).joined(separator: "/") 129 | } 130 | 131 | return out 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /Tests/ParsingTests/PlaylistParsingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistParsingTests.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 10/22/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Types 11 | import Parsing 12 | 13 | class ParsingTests: XCTestCase { 14 | func testMediaPlaylist() { 15 | 16 | let inputPlaylist = 17 | "#EXTM3U" + "\n" + 18 | "#EXT-X-PLAYLIST-TYPE:VOD" + "\n" + 19 | "#EXT-X-TARGETDURATION:3" + "\n" + 20 | "#EXT-X-VERSION:3" + "\n" + 21 | "#EXTINF:3.1," + "\n" + 22 | "s1.ts" + "\n" + 23 | "#EXTINF:3.0," + "\n" + 24 | "s2.ts" + "\n" + 25 | "#EXTINF:3.2," + "\n" + 26 | "../s3.ts" + "\n" + 27 | "#EXTINF:2.9," + "\n" + 28 | "alt/s4.ts" + "\n" + 29 | "#EXT-X-KEY:METHOD=AES-128,URI=\"ex.key\"" + "\n" + 30 | "#EXTINF:3.0," + "\n" + 31 | "s5.ts" + "\n" + 32 | "#EXTINF:3.2," + "\n" + 33 | "s6.ts" + "\n" + 34 | "#EXT-X-ENDLIST" + "\n" 35 | 36 | let urlString = "http://example.com/test/playlist.m3u8" 37 | let url = URL(string: urlString)! 38 | 39 | let s1URL = URL(string: "http://example.com/test/s1.ts")! 40 | let s2URL = URL(string: "s2.ts", relativeTo: url)! 41 | let s3URL = URL(string: "http://example.com/s3.ts")! 42 | let s4URL = URL(string: "http://example.com/test/alt/s4.ts")! 43 | let s5URL = URL(string: "s5.ts", relativeTo: url)! 44 | let s6URL = URL(string: "s6.ts", relativeTo: url)! 45 | 46 | let segments = [MediaSegment(uri: s1URL, duration: 3.1), 47 | MediaSegment(uri: s2URL, duration: 3.0), 48 | MediaSegment(uri: s3URL, duration: 3.2), 49 | MediaSegment(uri: s4URL, duration: 2.9)] 50 | 51 | let unencryptedSegments = [MediaSegment(uri: s5URL, duration: 3.0), 52 | MediaSegment(uri: s6URL, duration: 3.2)] 53 | let key = DecryptionKey(method: .AES128(URL(string: "ex.key")!)) 54 | let encryptedSegments = setDecryptionKey(key, forSegments: unencryptedSegments) 55 | 56 | let playlist: MediaPlaylist = MediaPlaylist(type: .VOD, version: 3, uri: url, targetDuration: 3, closed: true, start: nil, segments: segments + encryptedSegments, independentSegments: false, mediaSequence: 0) 57 | 58 | let parsedPlaylist = parseMediaPlaylist(string: inputPlaylist, atURL: url) 59 | 60 | XCTAssertEqual(playlist, parsedPlaylist) 61 | } 62 | 63 | func testMasterPlaylistLogRemainder() { 64 | var inputPlaylist: String 65 | inputPlaylist = "#EXTM3U" + "\n" 66 | 67 | let urlString = "http://example.com/test/playlist.m3u8" 68 | let url = URL(string: urlString)! 69 | 70 | let error = FakeOutputStream() 71 | let info = FakeOutputStream() 72 | let logger = HLSLogging.Default(thresholdLevel: .error, errorOut: error, infoOut: info) 73 | 74 | _ = parseMasterPlaylist(string: inputPlaylist, atURL: url, logger: logger) 75 | 76 | XCTAssertEqual(error.logs, ["NO REMAINDER"]) 77 | XCTAssertEqual(info.logs, []) 78 | 79 | inputPlaylist += "XYZ" 80 | error.logs = [] 81 | 82 | _ = parseMasterPlaylist(string: inputPlaylist, atURL: url, logger: logger) 83 | 84 | XCTAssertEqual(error.logs, []) 85 | XCTAssertEqual(info.logs, ["REMAINDER:\nXYZ"]) 86 | } 87 | 88 | func testMediaPlaylistLogRemainder() { 89 | var inputPlaylist: String 90 | inputPlaylist = "#EXTM3U" + "\n" 91 | 92 | let urlString = "http://example.com/test/playlist.m3u8" 93 | let url = URL(string: urlString)! 94 | 95 | let error = FakeOutputStream() 96 | let info = FakeOutputStream() 97 | let logger = HLSLogging.Default(thresholdLevel: .error, errorOut: error, infoOut: info) 98 | 99 | _ = parseMediaPlaylist(string: inputPlaylist, atURL: url, logger: logger) 100 | 101 | XCTAssertEqual(error.logs, ["NO REMAINDER"]) 102 | XCTAssertEqual(info.logs, []) 103 | 104 | inputPlaylist += "XYZ" 105 | error.logs = [] 106 | 107 | _ = parseMediaPlaylist(string: inputPlaylist, atURL: url, logger: logger) 108 | 109 | XCTAssertEqual(error.logs, []) 110 | XCTAssertEqual(info.logs, ["REMAINDER:\nXYZ"]) 111 | } 112 | 113 | static var allTests: [(String, (ParsingTests) -> () throws -> Void)] { 114 | return [ 115 | ("testMediaPlaylist", testMediaPlaylist) 116 | ] 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/Parsing/AttributeList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttributeList.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/5/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FFCParserCombinator 11 | 12 | // All comments in this file represent section 4.2 of the HTTP Live Streaming 13 | // RFC in order, in its entirety. 14 | // Accessed May 5, 2018 15 | 16 | /// [4.2](https://tools.ietf.org/html/rfc8216#section-4.2). Attribute Lists 17 | 18 | /** 19 | Certain tags have values that are attribute-lists. An attribute-list 20 | is a comma-separated list of attribute/value pairs with no 21 | whitespace. 22 | */ 23 | 24 | typealias AttributeList = [AttributeName: AttributeValue] 25 | 26 | typealias AttributePair = (AttributeName, AttributeValue) 27 | 28 | let attributeList = builtAttributeList 29 | <^> attribute.followed(by: ( "," *> attribute ).many, 30 | combine: { (single, list) -> [AttributePair] in 31 | [single] + list 32 | }) 33 | 34 | func builtAttributeList(attributes: [(AttributeName, AttributeValue)]) -> AttributeList { 35 | return attributes.reduce(AttributeList(), { (attributeList, keyValue) -> AttributeList in 36 | var newAttributeList = attributeList 37 | newAttributeList[keyValue.0] = keyValue.1 38 | return newAttributeList 39 | }) 40 | } 41 | 42 | /** 43 | An attribute/value pair has the following syntax: 44 | 45 | AttributeName=AttributeValue 46 | */ 47 | 48 | let attribute = attributeName <* "=" <&> attributeValue 49 | 50 | /** 51 | [4.2](https://tools.ietf.org/html/rfc8216#section-4.2) Continued 52 | 53 | An AttributeName is an unquoted string containing characters from the 54 | set [A..Z], [0..9] and '-'. Therefore, AttributeNames contain only 55 | uppercase letters, not lowercase. There MUST NOT be any whitespace 56 | between the AttributeName and the '=' character, nor between the '=' 57 | character and the AttributeValue. 58 | */ 59 | 60 | typealias AttributeName = String 61 | 62 | extension CharacterSet { 63 | static let forAttributeName = CharacterSet(charactersIn: "A"..."Z") 64 | .union(CharacterSet(charactersIn: "0"..."9")) 65 | .union(CharacterSet(charactersIn: "-")) 66 | } 67 | 68 | let attributeName = { AttributeName($0) } <^> CharacterSet.forAttributeName.parser().many1 69 | 70 | /** 71 | [4.2](https://tools.ietf.org/html/rfc8216#section-4.2) 72 | 73 | An AttributeValue is one of the following: 74 | 75 | * decimal-integer: an unquoted string of characters from the set 76 | [0..9] expressing an integer in base-10 arithmetic in the range 77 | from 0 to 2^64-1 (18446744073709551615). A decimal-integer may be 78 | from 1 to 20 characters long. 79 | 80 | * hexadecimal-sequence: an unquoted string of characters from the 81 | set [0..9] and [A..F] that is prefixed with 0x or 0X. The maximum 82 | length of a hexadecimal-sequence depends on its AttributeNames. 83 | 84 | * decimal-floating-point: an unquoted string of characters from the 85 | set [0..9] and '.' that expresses a non-negative floating-point 86 | number in decimal positional notation. 87 | 88 | * signed-decimal-floating-point: an unquoted string of characters 89 | from the set [0..9], '-', and '.' that expresses a signed 90 | floating-point number in decimal positional notation. 91 | 92 | * quoted-string: a string of characters within a pair of double 93 | quotes (0x22). The following characters MUST NOT appear in a 94 | quoted-string: line feed (0xA), carriage return (0xD), or double 95 | quote (0x22). Quoted-string AttributeValues SHOULD be constructed 96 | so that byte-wise comparison is sufficient to test two quoted- 97 | string AttributeValues for equality. Note that this implies case- 98 | sensitive comparison. 99 | 100 | * enumerated-string: an unquoted character string from a set that is 101 | explicitly defined by the AttributeName. An enumerated-string 102 | will never contain double quotes ("), commas (,), or whitespace. 103 | 104 | * decimal-resolution: two decimal-integers separated by the "x" 105 | character. The first integer is a horizontal pixel dimension 106 | (width); the second is a vertical pixel dimension (height). 107 | */ 108 | 109 | let attributeValue = hexSequence 110 | <|> decimalFloatingPoint 111 | <|> signedDecimalFloatingPoint 112 | <|> quotedString 113 | <|> decimalResolution 114 | <|> decimalInteger 115 | <|> enumeratedString 116 | 117 | /** 118 | The type of the AttributeValue for a given AttributeName is specified 119 | by the attribute definition. 120 | 121 | A given AttributeName MUST NOT appear more than once in a given 122 | attribute-list. Clients SHOULD refuse to parse such Playlists. 123 | */ 124 | -------------------------------------------------------------------------------- /Sources/Types/HLSCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HLSCore.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/13/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// EXT-X-START 12 | public struct StartIndicator: Equatable { 13 | let timeOffset: TimeInterval 14 | let preciseStart: Bool 15 | public init(at timeOffset: TimeInterval, preciseStart: Bool = false) { 16 | self.timeOffset = timeOffset 17 | self.preciseStart = preciseStart 18 | } 19 | } 20 | 21 | public enum MediaType: String { 22 | case Audio = "AUDIO" 23 | case Video = "VIDEO" 24 | case Subtitles = "SUBTITLES" 25 | case ClosedCaptions = "CLOSED-CAPTIONS" 26 | } 27 | 28 | /// EXT-X-MEDIA 29 | public struct Rendition: Equatable { 30 | public let type: MediaType 31 | public let uri: URL? 32 | public let groupID: String 33 | public let language: Language? 34 | public let associatedLanguage: Language? 35 | public let name: String 36 | public let defaultRendition: Bool 37 | public let forced: Bool 38 | 39 | public init(mediaType: MediaType, 40 | uri: URL?, 41 | groupID: String, 42 | language: Language?, 43 | associatedLanguage: Language?, 44 | name: String, 45 | defaultRendition: Bool = false, 46 | forced: Bool = false) { 47 | self.type = mediaType 48 | self.uri = uri 49 | self.groupID = groupID 50 | self.language = language 51 | self.associatedLanguage = language 52 | self.name = name 53 | self.defaultRendition = defaultRendition 54 | self.forced = forced 55 | } 56 | } 57 | 58 | public struct RenditionGroup { 59 | let groupId: String 60 | let type: MediaType 61 | 62 | let renditions: [Rendition] 63 | 64 | public init(groupId: String, type: MediaType, renditions: [Rendition]) { 65 | self.groupId = groupId 66 | self.type = type 67 | self.renditions = renditions 68 | 69 | defaultRendition = renditions.first(where: { $0.defaultRendition }) 70 | forcedRenditions = renditions.filter { $0.forced } 71 | } 72 | 73 | public let defaultRendition: Rendition? 74 | 75 | public let forcedRenditions: [Rendition] 76 | } 77 | 78 | /// Bits per second 79 | public struct Bitrate: Comparable { 80 | public let value: UInt 81 | 82 | public static func < (lhs: Bitrate, rhs: Bitrate) -> Bool { 83 | return lhs.value < rhs.value 84 | } 85 | 86 | public func toUIntMax() -> UInt64 { 87 | return UInt64(value) 88 | } 89 | 90 | public init(_ v: UInt64) { 91 | value = UInt(v) 92 | } 93 | } 94 | 95 | extension Bitrate: ExpressibleByIntegerLiteral { 96 | public init(integerLiteral value: UInt) { 97 | self.value = value 98 | } 99 | 100 | public typealias IntegerLiteralType = UInt 101 | } 102 | 103 | /** 104 | MIME type/subtypes representing different media formats. 105 | 106 | TODO: Implementation of RFC6381 would be useful. Right now, a `Codec` is a dumb 107 | `String`. 108 | https://tools.ietf.org/html/rfc6381 109 | */ 110 | public struct Codec: RawRepresentable, Equatable { 111 | public let rawValue: String 112 | 113 | public init(rawValue: String) { 114 | self.rawValue = rawValue 115 | } 116 | } 117 | 118 | /// EXT-X-STREAM-INF 119 | public struct StreamInfo: Equatable { 120 | /// Peak segment bitrate in bits per second 121 | public let bandwidth: Bitrate 122 | public let averageBandwidth: Bitrate? 123 | public let codecs: [Codec] 124 | public let resolution: Resolution? 125 | public let frameRate: Double? 126 | 127 | public let uri: URL 128 | 129 | public init(bandwidth: Bitrate, 130 | averageBandwidth: Bitrate?, 131 | codecs: [Codec], 132 | resolution: Resolution?, 133 | frameRate: Double?, uri: URL) { 134 | self.bandwidth = bandwidth 135 | self.averageBandwidth = averageBandwidth 136 | self.codecs = codecs 137 | self.resolution = resolution 138 | self.frameRate = frameRate 139 | self.uri = uri 140 | } 141 | } 142 | 143 | /// Media 144 | 145 | public struct MediaSegment { 146 | private var playlist: MediaPlaylist? 147 | 148 | public var resource: MediaResource 149 | 150 | public init(uri: URL, 151 | duration: TimeInterval, 152 | title: String? = nil, 153 | byteRange: CountableClosedRange? = nil, 154 | decryptionKey: DecryptionKey? = nil, 155 | date: Date? = nil, 156 | mediaInitializationSection: MediaInitializationSection? = nil) { 157 | resource = MediaResource(uri: uri) 158 | self.duration = duration 159 | self.title = title 160 | self.byteRange = byteRange 161 | self.decryptionKey = decryptionKey 162 | self.programDateTime = date 163 | self.mediaInitializationSection = mediaInitializationSection 164 | } 165 | 166 | // EXTINF 167 | 168 | public let duration: TimeInterval 169 | let title: String? 170 | 171 | // EXT-X-BYTERANGE 172 | 173 | public let byteRange: CountableClosedRange? 174 | 175 | public var decryptionKey: DecryptionKey? 176 | 177 | public var programDateTime: Date? 178 | 179 | public var mediaInitializationSection: MediaInitializationSection? 180 | 181 | } 182 | 183 | extension MediaSegment: Equatable { } 184 | 185 | public struct MediaInitializationSection { 186 | public let uri: URL 187 | public let byteRange: CountableClosedRange? 188 | 189 | public init(uri: URL, byteRange: CountableClosedRange?) { 190 | self.uri = uri 191 | self.byteRange = byteRange 192 | } 193 | } 194 | 195 | extension MediaInitializationSection: Equatable {} 196 | 197 | public struct MediaResource: Equatable { 198 | public let uri: URL 199 | } 200 | -------------------------------------------------------------------------------- /Sources/Parsing/Tags.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tags.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/5/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Types 11 | import FFCParserCombinator 12 | 13 | enum HLS { 14 | 15 | // MARK: Basic Tags 16 | 17 | enum Playlist { 18 | 19 | static let TagParser = AnyTag.playlist <^> EXTVERSION 20 | <|> EXTXINDEPENDENTSEGMENTS 21 | <|> URLPseudoTag 22 | <|> COMMENT 23 | 24 | static let StartTag: Parser = "#EXTM3U" 25 | 26 | static let EXTVERSION = Tag.version <^> "#EXT-X-VERSION:" *> UInt.parser 27 | 28 | static let EXTXINDEPENDENTSEGMENTS = { _ in Tag.independentSegments } <^> "#EXT-X-INDEPENDENT-SEGMENTS" 29 | 30 | static let EXTXSTART = Tag.startIndicator <^> ( StartIndicator.init <^!> ( "#EXT-X-START:" *> attributeList )) 31 | 32 | static let COMMENT = Tag.comment <^> "#" *> ({ (chars: [Character]) in String(chars) } <^> CharacterSet(charactersIn: "\r\n").inverted.parser().many) 33 | 34 | static let URLPseudoTag = Tag.url <^> TypeParser.url 35 | 36 | enum Master { 37 | static let TagParser = MasterTagParser <|> Playlist.TagParser 38 | 39 | static let MasterTagParser = AnyTag.master <^> EXTXMEDIA 40 | <|> EXTXSTREAMINF 41 | <|> EXTXIFRAMESTREAMINF 42 | <|> EXTXSESSIONDATA 43 | <|> EXTXSESSIONKEY 44 | 45 | static let EXTXMEDIA = Tag.MasterPlaylist.media <^> ( Rendition.init <^!> "#EXT-X-MEDIA:" *> attributeList) 46 | 47 | static let EXTXSTREAMINF = Tag.MasterPlaylist.streamInfo <^> ( StreamInfo.init <^!> "#EXT-X-STREAM-INF:" *> attributeList <* BasicParser.newline.many <&> TypeParser.url) 48 | 49 | static let EXTXIFRAMESTREAMINF = Tag.MasterPlaylist.iFramesStreamInfo <^> "#EXT-X-I-FRAME-STREAM-INF:" *> attributeList 50 | 51 | static let EXTXSESSIONDATA = Tag.MasterPlaylist.sessionData <^> "#EXT-X-SESSION-DATA:" *> attributeList 52 | 53 | static let EXTXSESSIONKEY = Tag.MasterPlaylist.sessionKey <^> "#EXT-X-SESSION-KEY:" *> attributeList 54 | } 55 | 56 | enum Media { 57 | 58 | static let TagParser = MediaTagParser <|> Segment.TagParser <|> Playlist.TagParser 59 | 60 | static let MediaTagParser = AnyTag.media <^> EXTXTARGETDURATION 61 | <|> EXTXMEDIASEQUENCE 62 | <|> EXTXDISCONTINUITYSEQUENCE 63 | <|> EXTXENDLIST 64 | <|> EXTXPLAYLISTTYPE 65 | <|> EXTXIFRAMESONLY 66 | 67 | static let EXTXTARGETDURATION = Tag.MediaPlaylist.targetDuration <^> "#EXT-X-TARGETDURATION:" *> decimalInteger 68 | 69 | static let EXTXMEDIASEQUENCE = Tag.MediaPlaylist.mediaSequence <^> "#EXT-X-MEDIA-SEQUENCE:" *> decimalInteger 70 | 71 | static let EXTXDISCONTINUITYSEQUENCE = Tag.MediaPlaylist.discontinuitySequence <^> "#EXT-X-DISCONTINUITY-SEQUENCE:" *> decimalInteger 72 | 73 | static let EXTXENDLIST = { _ in Tag.MediaPlaylist.endList } <^> "#EXT-X-ENDLIST" 74 | 75 | static let EXTXPLAYLISTTYPE = Tag.MediaPlaylist.playlistType <^> "#EXT-X-PLAYLIST-TYPE:" *> enumeratedString 76 | 77 | static let EXTXIFRAMESONLY = { _ in Tag.MediaPlaylist.iFramesOnly } <^> "#EXT-X-I-FRAMES-ONLY" 78 | 79 | enum Segment { 80 | 81 | static let TagParser = AnyTag.segment <^> EXTINF 82 | <|> EXTXBYTERANGE 83 | <|> EXTXDISCONTINUITY 84 | <|> EXTXKEY 85 | <|> EXTXMAP 86 | <|> EXTXPROGRAMDATETIME 87 | <|> EXTXDATERANGE 88 | 89 | static let EXTINF = Tag.MediaPlaylist.Segment.inf <^> 90 | "#EXTINF:" *> (( decimalFloatingPoint <|> decimalInteger ) <* "," 91 | <&> ({ String($0) } <^> (CharacterSet.newlines.inverted).parser().many1).optional) 92 | 93 | static let EXTXBYTERANGE = Tag.MediaPlaylist.Segment.byteRange <^> ( "#EXT-X-BYTERANGE:" *> TypeParser.byteRange ) 94 | 95 | static let EXTXDISCONTINUITY = { _ in Tag.MediaPlaylist.Segment.discontinuity } <^> "#EXT-X-DISCONTINUITY" 96 | 97 | static let EXTXKEY = Tag.MediaPlaylist.Segment.key <^> (DecryptionKey.init <^!> "#EXT-X-KEY:" *> attributeList ) 98 | 99 | static let EXTXMAP = Tag.MediaPlaylist.Segment.map <^> ( MediaInitializationSection.init <^!> "#EXT-X-MAP:" *> attributeList ) 100 | 101 | static let EXTXPROGRAMDATETIME = Tag.MediaPlaylist.Segment.programDateTime <^> "#EXT-X-PROGRAM-DATE-TIME:" *> TypeParser.date 102 | 103 | static let EXTXDATERANGE = Tag.MediaPlaylist.Segment.dateRange <^> "#EXT-X-DATERANGE:" *> attributeList 104 | } 105 | } 106 | } 107 | } 108 | // MARK: Tag Taxonomy 109 | 110 | enum AnyTag { 111 | case playlist(Tag) 112 | case media(Tag.MediaPlaylist) 113 | case segment(Tag.MediaPlaylist.Segment) 114 | case master(Tag.MasterPlaylist) 115 | } 116 | 117 | enum Tag { 118 | 119 | case version(UInt) 120 | 121 | case independentSegments 122 | case startIndicator(StartIndicator) 123 | 124 | case comment(String) 125 | 126 | /// Not a tag, but definitely a top-level element. Makes parsing easier. 127 | case url(URL) 128 | 129 | enum MediaPlaylist { 130 | case targetDuration(AttributeValue) 131 | case mediaSequence(AttributeValue) 132 | case discontinuitySequence(AttributeValue) 133 | case endList 134 | case playlistType(AttributeValue) 135 | case iFramesOnly 136 | 137 | enum Segment { 138 | case inf(AttributeValue, String?) 139 | case byteRange(CountableClosedRange) 140 | case discontinuity 141 | 142 | case key(DecryptionKey) 143 | case map(MediaInitializationSection) 144 | 145 | case programDateTime(Date?) 146 | case dateRange(AttributeList) 147 | } 148 | } 149 | 150 | enum MasterPlaylist { 151 | case media(Rendition) 152 | case streamInfo(StreamInfo) 153 | case iFramesStreamInfo(AttributeList) 154 | case sessionData(AttributeList) 155 | case sessionKey(AttributeList) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/Parsing/MediaPlaylistParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaPlaylistParser.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 10/16/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Types 11 | import FFCParserCombinator 12 | 13 | let newlines = BasicParser.newline.many1 14 | 15 | private struct OpenMediaSegment { 16 | var duration: TimeInterval? 17 | var title: String? 18 | var byteRange: CountableClosedRange? 19 | var programDateTime: Date? 20 | var discontinuity: Bool? 21 | var mediaInitializationSection: MediaInitializationSection? 22 | } 23 | 24 | private struct PlaylistBuilder { 25 | var url: URL 26 | var playlistType: MediaPlaylist.PlaylistType? 27 | var version: UInt = 1 28 | var duration: TimeInterval? 29 | var start: StartIndicator? 30 | var segments: [MediaSegment] = [] 31 | var closed: Bool = false 32 | var independentSegments: Bool = false 33 | var mediaSequence: UInt = 0 34 | 35 | var activeKey: DecryptionKey? 36 | var activeMediaInitializationSection: MediaInitializationSection? 37 | 38 | var openSegment: OpenMediaSegment? 39 | 40 | var fatalTag: AnyTag? 41 | 42 | init(rootURL: URL) { 43 | url = rootURL 44 | } 45 | } 46 | 47 | public func parseMediaPlaylist(string: String, atURL url: URL, logger: Logger = HLSLogging.Default()) -> MediaPlaylist? { 48 | let parser = HLS.Playlist.StartTag *> newlines *> ( HLS.Playlist.Media.TagParser <* newlines ).many 49 | 50 | let parseResult = parser.run(string) 51 | 52 | if let remainingChars = parseResult?.1, (remainingChars.count > 0) { 53 | logger.log("REMAINDER:\n\(String(remainingChars))", level: .error) 54 | } else { 55 | logger.log("NO REMAINDER", level: .info) 56 | } 57 | 58 | guard let tags = parseResult?.0 else { 59 | return nil 60 | } 61 | 62 | let playlistBuilder = tags.reduce(PlaylistBuilder(rootURL: url), reduceTag) 63 | 64 | guard playlistBuilder.fatalTag == nil else { 65 | logger.log("Fatal tag encountered in media playlist: \(playlistBuilder.fatalTag!)", level: .fatal) 66 | return nil 67 | } 68 | 69 | guard let targetDuration = playlistBuilder.duration else { 70 | return nil 71 | } 72 | 73 | return MediaPlaylist(type: playlistBuilder.playlistType, 74 | version: playlistBuilder.version, 75 | uri: playlistBuilder.url, 76 | targetDuration: targetDuration, 77 | closed: playlistBuilder.closed, 78 | start: playlistBuilder.start, 79 | segments: playlistBuilder.segments, 80 | independentSegments: playlistBuilder.independentSegments, 81 | mediaSequence: playlistBuilder.mediaSequence) 82 | } 83 | 84 | // swiftlint:disable function_body_length cyclomatic_complexity 85 | private func reduceTag(state: PlaylistBuilder, tag: AnyTag) -> PlaylistBuilder { 86 | 87 | var builder = state 88 | 89 | switch tag { 90 | case let .playlist(playlist): 91 | switch playlist { 92 | case let .version(version): 93 | builder.version = version 94 | case .independentSegments: 95 | builder.independentSegments = true 96 | case let .startIndicator(start): 97 | builder.start = start 98 | case let .url(segmentURL): 99 | let openSegment = builder.openSegment 100 | guard let duration = openSegment?.duration else { 101 | builder.fatalTag = tag 102 | break 103 | } 104 | let fullSegmentURL = URL(string: segmentURL.absoluteString, relativeTo: state.url)! 105 | let segment = MediaSegment(uri: fullSegmentURL, 106 | duration: duration, 107 | title: openSegment?.title, 108 | byteRange: openSegment?.byteRange, 109 | decryptionKey: builder.activeKey, 110 | date: openSegment?.programDateTime, 111 | mediaInitializationSection: builder.activeMediaInitializationSection) 112 | builder.segments.append(segment) 113 | builder.openSegment = nil 114 | case .comment(_): 115 | break 116 | } 117 | case let .media(media): 118 | switch media { 119 | case let .targetDuration(duration): 120 | switch duration { 121 | case let .decimalFloatingPoint(float): 122 | builder.duration = float 123 | case let .decimalInteger(int): 124 | builder.duration = TimeInterval(int) 125 | default: 126 | builder.fatalTag = tag 127 | } 128 | case let .mediaSequence(sequence): 129 | switch sequence { 130 | case let .decimalInteger(sequence): 131 | builder.mediaSequence = sequence 132 | default: 133 | builder.fatalTag = tag 134 | } 135 | case .discontinuitySequence(_): 136 | // TODO: Discontinuity Sequence Unimplemented 137 | break 138 | case .endList: 139 | builder.closed = true 140 | case let .playlistType(type): 141 | switch type { 142 | case let .enumeratedString(string): 143 | builder.playlistType = MediaPlaylist.PlaylistType(rawValue: string.rawValue) 144 | default: 145 | builder.fatalTag = tag 146 | } 147 | case .iFramesOnly: 148 | // TODO: i-frame lists not implemented 149 | break 150 | } 151 | case let .segment(segment): 152 | var openSegment = builder.openSegment ?? OpenMediaSegment() 153 | 154 | switch segment { 155 | case let .inf(duration, title): 156 | switch duration { 157 | case let .decimalFloatingPoint(float): 158 | openSegment.duration = float 159 | case let .decimalInteger(int): 160 | openSegment.duration = TimeInterval(int) 161 | default: 162 | builder.fatalTag = tag 163 | } 164 | openSegment.title = title 165 | case let .byteRange(byteRange): 166 | openSegment.byteRange = byteRange 167 | case .discontinuity: 168 | // Probably applies to the _next_ segment, not the current one. 169 | openSegment.discontinuity = true 170 | case let .key(key): 171 | builder.activeKey = key 172 | case let .map(mediaInitialization): 173 | let mediaInitializationURI = URL(string: mediaInitialization.uri.absoluteString, relativeTo: state.url)! 174 | let mediaInitializationSection = MediaInitializationSection(uri: mediaInitializationURI, 175 | byteRange: mediaInitialization.byteRange) 176 | builder.activeMediaInitializationSection = mediaInitializationSection 177 | case let .programDateTime(date): 178 | openSegment.programDateTime = date 179 | case .dateRange(_): 180 | // TODO: Date Range 181 | break 182 | } 183 | openSegment.mediaInitializationSection = builder.activeMediaInitializationSection 184 | builder.openSegment = openSegment 185 | 186 | case .master(_): 187 | builder.fatalTag = tag 188 | } 189 | 190 | return builder 191 | } 192 | // swiftlint:enable function_body_length cyclomatic_complexity} 193 | -------------------------------------------------------------------------------- /Tests/TypesTests/HLSCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HLSCoreTests.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 8/13/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Types 11 | 12 | class RenditionGroupTests: XCTestCase { 13 | 14 | func testNoDefaultRendition() { 15 | let renditions = [Rendition(mediaType: .Audio, 16 | uri: nil, 17 | groupID: "Group", 18 | language: Language.en, 19 | associatedLanguage: nil, 20 | name: "R1"), 21 | Rendition(mediaType: .Audio, 22 | uri: nil, 23 | groupID: "Group", 24 | language: Language.en, 25 | associatedLanguage: nil, 26 | name: "R2"), 27 | Rendition(mediaType: .Audio, 28 | uri: nil, 29 | groupID: "Group", 30 | language: Language.en, 31 | associatedLanguage: nil, 32 | name: "R3")] 33 | let group = RenditionGroup(groupId: "Group0", type: .Video, renditions: renditions) 34 | XCTAssertNil(group.defaultRendition) 35 | } 36 | 37 | func testNoForcedRenditions() { 38 | let renditions = [Rendition(mediaType: .Audio, 39 | uri: nil, 40 | groupID: "Group", 41 | language: Language.en, 42 | associatedLanguage: nil, 43 | name: "R1"), 44 | Rendition(mediaType: .Audio, 45 | uri: nil, 46 | groupID: "Group", 47 | language: Language.en, 48 | associatedLanguage: nil, 49 | name: "R2"), 50 | Rendition(mediaType: .Audio, 51 | uri: nil, 52 | groupID: "Group", 53 | language: Language.en, 54 | associatedLanguage: nil, 55 | name: "R3")] 56 | let group = RenditionGroup(groupId: "Group0", type: .Video, renditions: renditions) 57 | XCTAssertEqual(group.forcedRenditions, []) 58 | } 59 | 60 | func testDefaultRendition() { 61 | let renditions = [Rendition(mediaType: .Audio, 62 | uri: nil, 63 | groupID: "Group", 64 | language: Language.en, 65 | associatedLanguage: nil, 66 | name: "R1"), 67 | Rendition(mediaType: .Audio, 68 | uri: nil, 69 | groupID: "Group", 70 | language: Language.en, 71 | associatedLanguage: nil, 72 | name: "R2", 73 | defaultRendition: true), 74 | Rendition(mediaType: .Audio, 75 | uri: nil, 76 | groupID: "Group", 77 | language: Language.en, 78 | associatedLanguage: nil, 79 | name: "R3")] 80 | let group = RenditionGroup(groupId: "Group0", type: .Video, renditions: renditions) 81 | XCTAssertEqual(group.defaultRendition!, renditions[1]) 82 | } 83 | 84 | func testForcedRendition() { 85 | let renditions = [Rendition(mediaType: .Audio, 86 | uri: nil, 87 | groupID: "Group", 88 | language: Language.en, 89 | associatedLanguage: nil, 90 | name: "R1"), 91 | Rendition(mediaType: .Audio, 92 | uri: nil, 93 | groupID: "Group", 94 | language: Language.en, 95 | associatedLanguage: nil, 96 | name: "R2", 97 | defaultRendition: true, 98 | forced: true), 99 | Rendition(mediaType: .Audio, 100 | uri: nil, 101 | groupID: "Group", 102 | language: Language.en, 103 | associatedLanguage: nil, 104 | name: "R3", 105 | forced: true)] 106 | let group = RenditionGroup(groupId: "Group0", type: .Video, renditions: renditions) 107 | XCTAssertEqual(group.forcedRenditions, [renditions[1], renditions[2]]) 108 | } 109 | 110 | static var allTests: [(String, (RenditionGroupTests) -> () throws -> Void)] { 111 | return [("testNoDefaultRendition", testNoDefaultRendition), 112 | ("testNoForcedRenditions", testNoForcedRenditions), 113 | ("testDefaultRendition", testDefaultRendition), 114 | ("testForcedRendition", testForcedRendition) 115 | ] 116 | } 117 | } 118 | 119 | class StreamInfoTests: XCTestCase { 120 | 121 | func testSorting() { 122 | let a = StreamInfo(bandwidth: 1, 123 | averageBandwidth: nil, 124 | codecs: [Codec(rawValue: "h.264")], 125 | resolution: Resolution(width: 3840, height: 2160), 126 | frameRate: 60, 127 | uri: URL(string:"/")!) 128 | let b = StreamInfo(bandwidth: 2, 129 | averageBandwidth: nil, 130 | codecs: [Codec(rawValue: "h.264")], 131 | resolution: Resolution(width: 1920, height: 1080), 132 | frameRate: 24, 133 | uri: URL(string:"/")!) 134 | let c = StreamInfo(bandwidth: 30, 135 | averageBandwidth: nil, 136 | codecs: [Codec(rawValue: "h.264")], 137 | resolution: Resolution(width: 1280, height: 720), 138 | frameRate: 30, 139 | uri: URL(string:"/")!) 140 | 141 | let bandwidth = [a,b,c].sorted(by: { $0.bandwidth < $1.bandwidth }) 142 | XCTAssertEqual(bandwidth, [a, b, c]) 143 | 144 | let framerate = [a,b,c].sorted(by: { $0.frameRate! < $1.frameRate! }) 145 | XCTAssertEqual(framerate, [b, c, a]) 146 | 147 | let height = [a,b,c].sorted(by: { $0.resolution!.height < $1.resolution!.height }) 148 | XCTAssertEqual(height, [c, b, a]) 149 | } 150 | } 151 | 152 | class BitrateTests: XCTestCase { 153 | func testBitrateEquatable() { 154 | for _ in 1...100 { 155 | let randomValue = UInt64(UInt.random(in: UInt.min...UInt.max)) 156 | XCTAssertEqual(Bitrate(randomValue), Bitrate(randomValue)) 157 | } 158 | } 159 | 160 | func testBitrateComparable() { 161 | for _ in 1...100 { 162 | let randomValueA = UInt64(UInt.random(in: UInt.min...UInt.max)) 163 | let randomValueB = UInt64(UInt.random(in: UInt.min...UInt.max)) 164 | 165 | XCTAssertEqual(Bitrate(randomValueA) > Bitrate(randomValueB), randomValueA > randomValueB) 166 | XCTAssertEqual(Bitrate(randomValueA) < Bitrate(randomValueB), randomValueA < randomValueB) 167 | XCTAssertEqual(Bitrate(randomValueA) <= Bitrate(randomValueB), randomValueA <= randomValueB) 168 | XCTAssertEqual(Bitrate(randomValueA) >= Bitrate(randomValueB), randomValueA >= randomValueB) 169 | } 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /Tests/SerializationTests/AutoEncoding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutoEncoding.swift 3 | // ParsingTests 4 | // 5 | // Created by Fabián Cañas on 12/27/18. 6 | // 7 | 8 | import XCTest 9 | // Here we test public behavior. We shouldn't import the module as testable 10 | // because visibility is part of our contract. 11 | import Serialization 12 | import Types 13 | import Parsing 14 | 15 | class AutoEncoding: XCTestCase { 16 | 17 | func testMediaPlaylist() { 18 | let url = URL(string: "https://www.example.com/media.m3u8")! 19 | 20 | let parsed = parseMediaPlaylist(string: mediaPlaylist, atURL: url)! 21 | 22 | let rebuilt = MediaPlaylistSerializer().serialize(parsed).value! 23 | 24 | AssertMatchMultilineString(mediaPlaylist, rebuilt, separator: "\n") // Indicates per-line differences 25 | XCTAssertEqual(mediaPlaylist, rebuilt) // Sanity check for custom matcher 26 | } 27 | 28 | } 29 | 30 | private let mediaPlaylist = 31 | """ 32 | #EXTM3U 33 | #EXT-X-TARGETDURATION:6 34 | #EXT-X-VERSION:7 35 | #EXT-X-MEDIA-SEQUENCE:1 36 | #EXT-X-PLAYLIST-TYPE:VOD 37 | #EXT-X-INDEPENDENT-SEGMENTS 38 | #EXT-X-MAP:URI="main.mp4",BYTERANGE="720@0" 39 | #EXTINF:6.0, 40 | #EXT-X-BYTERANGE:540482@720 41 | main.mp4 42 | #EXTINF:6.0, 43 | #EXT-X-BYTERANGE:551488@541202 44 | main.mp4 45 | #EXTINF:6.0, 46 | #EXT-X-BYTERANGE:549405@1092690 47 | main.mp4 48 | #EXTINF:6.0, 49 | #EXT-X-BYTERANGE:551782@1642095 50 | main.mp4 51 | #EXT-X-KEY:METHOD=AES-128,URI="ex.key" 52 | #EXTINF:6.0, 53 | #EXT-X-BYTERANGE:548996@2193877 54 | main.mp4 55 | #EXTINF:6.0, 56 | #EXT-X-BYTERANGE:549160@2742873 57 | main.mp4 58 | #EXTINF:6.0, 59 | #EXT-X-BYTERANGE:550953@3292033 60 | main.mp4 61 | #EXTINF:6.0, 62 | #EXT-X-BYTERANGE:549499@3842986 63 | main.mp4 64 | #EXTINF:6.0, 65 | #EXT-X-BYTERANGE:551123@4392485 66 | main.mp4 67 | #EXTINF:6.0, 68 | #EXT-X-BYTERANGE:550296@4943608 69 | main.mp4 70 | #EXTINF:6.0, 71 | #EXT-X-BYTERANGE:548446@5493904 72 | main.mp4 73 | #EXTINF:6.0, 74 | #EXT-X-BYTERANGE:552205@6042350 75 | main.mp4 76 | #EXTINF:6.0, 77 | #EXT-X-BYTERANGE:549949@6594555 78 | main.mp4 79 | #EXTINF:6.0, 80 | #EXT-X-BYTERANGE:552062@7144504 81 | main.mp4 82 | #EXTINF:6.0, 83 | #EXT-X-BYTERANGE:548345@7696566 84 | main.mp4 85 | #EXTINF:6.0, 86 | #EXT-X-BYTERANGE:548046@8244911 87 | main.mp4 88 | #EXTINF:6.0, 89 | #EXT-X-BYTERANGE:549915@8792957 90 | main.mp4 91 | #EXTINF:6.0, 92 | #EXT-X-BYTERANGE:549905@9342872 93 | main.mp4 94 | #EXT-X-KEY:METHOD=AES-128,URI="ex2.key" 95 | #EXTINF:6.0, 96 | #EXT-X-BYTERANGE:550852@9892777 97 | main.mp4 98 | #EXTINF:6.0, 99 | #EXT-X-BYTERANGE:550464@10443629 100 | main.mp4 101 | #EXTINF:6.0, 102 | #EXT-X-BYTERANGE:550749@10994093 103 | main.mp4 104 | #EXTINF:6.0, 105 | #EXT-X-BYTERANGE:549749@11544842 106 | main.mp4 107 | #EXTINF:6.0, 108 | #EXT-X-BYTERANGE:548235@12094591 109 | main.mp4 110 | #EXTINF:6.0, 111 | #EXT-X-BYTERANGE:551026@12642826 112 | main.mp4 113 | #EXTINF:6.0, 114 | #EXT-X-BYTERANGE:549459@13193852 115 | main.mp4 116 | #EXTINF:6.0, 117 | #EXT-X-BYTERANGE:549475@13743311 118 | main.mp4 119 | #EXTINF:6.0, 120 | #EXT-X-BYTERANGE:551571@14292786 121 | main.mp4 122 | #EXTINF:6.0, 123 | #EXT-X-BYTERANGE:549220@14844357 124 | main.mp4 125 | #EXTINF:6.0, 126 | #EXT-X-BYTERANGE:553879@15393577 127 | main.mp4 128 | #EXTINF:6.0, 129 | #EXT-X-BYTERANGE:551318@15947456 130 | main.mp4 131 | #EXTINF:6.0, 132 | #EXT-X-BYTERANGE:550114@16498774 133 | main.mp4 134 | #EXTINF:6.0, 135 | #EXT-X-BYTERANGE:548555@17048888 136 | main.mp4 137 | #EXTINF:6.0, 138 | #EXT-X-BYTERANGE:548237@17597443 139 | main.mp4 140 | #EXTINF:6.0, 141 | #EXT-X-BYTERANGE:549431@18145680 142 | main.mp4 143 | #EXTINF:6.0, 144 | #EXT-X-BYTERANGE:550584@18695111 145 | main.mp4 146 | #EXTINF:6.0, 147 | #EXT-X-BYTERANGE:551212@19245695 148 | main.mp4 149 | #EXTINF:6.0, 150 | #EXT-X-BYTERANGE:548992@19796907 151 | main.mp4 152 | #EXTINF:6.0, 153 | #EXT-X-BYTERANGE:549999@20345899 154 | main.mp4 155 | #EXTINF:6.0, 156 | #EXT-X-BYTERANGE:549927@20895898 157 | main.mp4 158 | #EXTINF:6.0, 159 | #EXT-X-BYTERANGE:550652@21445825 160 | main.mp4 161 | #EXTINF:6.0, 162 | #EXT-X-BYTERANGE:550410@21996477 163 | main.mp4 164 | #EXTINF:6.0, 165 | #EXT-X-BYTERANGE:551015@22546887 166 | main.mp4 167 | #EXTINF:6.0, 168 | #EXT-X-BYTERANGE:549605@23097902 169 | main.mp4 170 | #EXTINF:6.0, 171 | #EXT-X-BYTERANGE:552853@23647507 172 | main.mp4 173 | #EXTINF:6.0, 174 | #EXT-X-BYTERANGE:550513@24200360 175 | main.mp4 176 | #EXTINF:6.0, 177 | #EXT-X-BYTERANGE:551360@24750873 178 | main.mp4 179 | #EXTINF:6.0, 180 | #EXT-X-BYTERANGE:551846@25302233 181 | main.mp4 182 | #EXTINF:6.0, 183 | #EXT-X-BYTERANGE:548689@25854079 184 | main.mp4 185 | #EXTINF:6.0, 186 | #EXT-X-BYTERANGE:549432@26402768 187 | main.mp4 188 | #EXTINF:6.0, 189 | #EXT-X-BYTERANGE:548816@26952200 190 | main.mp4 191 | #EXTINF:6.0, 192 | #EXT-X-BYTERANGE:552351@27501016 193 | main.mp4 194 | #EXTINF:6.0, 195 | #EXT-X-BYTERANGE:552291@28053367 196 | main.mp4 197 | #EXTINF:6.0, 198 | #EXT-X-BYTERANGE:550550@28605658 199 | main.mp4 200 | #EXTINF:6.0, 201 | #EXT-X-BYTERANGE:552528@29156208 202 | main.mp4 203 | #EXTINF:6.0, 204 | #EXT-X-BYTERANGE:551020@29708736 205 | main.mp4 206 | #EXTINF:6.0, 207 | #EXT-X-BYTERANGE:550283@30259756 208 | main.mp4 209 | #EXTINF:6.0, 210 | #EXT-X-BYTERANGE:552273@30810039 211 | main.mp4 212 | #EXTINF:6.0, 213 | #EXT-X-BYTERANGE:549643@31362312 214 | main.mp4 215 | #EXTINF:6.0, 216 | #EXT-X-BYTERANGE:550361@31911955 217 | main.mp4 218 | #EXTINF:6.0, 219 | #EXT-X-BYTERANGE:549987@32462316 220 | main.mp4 221 | #EXT-X-KEY:METHOD=NONE 222 | #EXTINF:6.0, 223 | #EXT-X-BYTERANGE:549423@33012303 224 | main.mp4 225 | #EXTINF:6.0, 226 | #EXT-X-BYTERANGE:551090@33561726 227 | main.mp4 228 | #EXTINF:6.0, 229 | #EXT-X-BYTERANGE:550027@34112816 230 | main.mp4 231 | #EXTINF:6.0, 232 | #EXT-X-BYTERANGE:552846@34662843 233 | main.mp4 234 | #EXTINF:6.0, 235 | #EXT-X-BYTERANGE:550031@35215689 236 | main.mp4 237 | #EXTINF:6.0, 238 | #EXT-X-BYTERANGE:549515@35765720 239 | main.mp4 240 | #EXTINF:6.0, 241 | #EXT-X-BYTERANGE:551206@36315235 242 | main.mp4 243 | #EXTINF:6.0, 244 | #EXT-X-BYTERANGE:551986@36866441 245 | main.mp4 246 | #EXTINF:6.0, 247 | #EXT-X-BYTERANGE:553570@37418427 248 | main.mp4 249 | #EXTINF:6.0, 250 | #EXT-X-BYTERANGE:550846@37971997 251 | main.mp4 252 | #EXTINF:6.0, 253 | #EXT-X-BYTERANGE:549565@38522843 254 | main.mp4 255 | #EXTINF:6.0, 256 | #EXT-X-BYTERANGE:550589@39072408 257 | main.mp4 258 | #EXTINF:6.0, 259 | #EXT-X-BYTERANGE:549628@39622997 260 | main.mp4 261 | #EXTINF:6.0, 262 | #EXT-X-BYTERANGE:553471@40172625 263 | main.mp4 264 | #EXTINF:6.0, 265 | #EXT-X-BYTERANGE:551277@40726096 266 | main.mp4 267 | #EXTINF:6.0, 268 | #EXT-X-BYTERANGE:548932@41277373 269 | main.mp4 270 | #EXTINF:6.0, 271 | #EXT-X-BYTERANGE:549599@41826305 272 | main.mp4 273 | #EXTINF:6.0, 274 | #EXT-X-BYTERANGE:547857@42375904 275 | main.mp4 276 | #EXTINF:6.0, 277 | #EXT-X-BYTERANGE:549411@42923761 278 | main.mp4 279 | #EXTINF:6.0, 280 | #EXT-X-BYTERANGE:548882@43473172 281 | main.mp4 282 | #EXTINF:6.0, 283 | #EXT-X-BYTERANGE:549596@44022054 284 | main.mp4 285 | #EXTINF:6.0, 286 | #EXT-X-BYTERANGE:552280@44571650 287 | main.mp4 288 | #EXTINF:6.0, 289 | #EXT-X-BYTERANGE:550035@45123930 290 | main.mp4 291 | #EXTINF:6.0, 292 | #EXT-X-BYTERANGE:552248@45673965 293 | main.mp4 294 | #EXTINF:6.0, 295 | #EXT-X-BYTERANGE:550139@46226213 296 | main.mp4 297 | #EXTINF:6.0, 298 | #EXT-X-BYTERANGE:548887@46776352 299 | main.mp4 300 | #EXTINF:6.0, 301 | #EXT-X-BYTERANGE:549391@47325239 302 | main.mp4 303 | #EXTINF:6.0, 304 | #EXT-X-BYTERANGE:551518@47874630 305 | main.mp4 306 | #EXTINF:6.0, 307 | #EXT-X-BYTERANGE:552012@48426148 308 | main.mp4 309 | #EXTINF:6.0, 310 | #EXT-X-BYTERANGE:549835@48978160 311 | main.mp4 312 | #EXTINF:6.0, 313 | #EXT-X-BYTERANGE:551578@49527995 314 | main.mp4 315 | #EXTINF:6.0, 316 | #EXT-X-BYTERANGE:552680@50079573 317 | main.mp4 318 | #EXTINF:6.0, 319 | #EXT-X-BYTERANGE:549329@50632253 320 | main.mp4 321 | #EXTINF:6.0, 322 | #EXT-X-BYTERANGE:549482@51181582 323 | main.mp4 324 | #EXTINF:6.0, 325 | #EXT-X-BYTERANGE:551513@51731064 326 | main.mp4 327 | #EXTINF:6.0, 328 | #EXT-X-BYTERANGE:552357@52282577 329 | main.mp4 330 | #EXTINF:6.0, 331 | #EXT-X-BYTERANGE:553546@52834934 332 | main.mp4 333 | #EXTINF:6.0, 334 | #EXT-X-BYTERANGE:552617@53388480 335 | main.mp4 336 | #EXTINF:6.0, 337 | #EXT-X-BYTERANGE:553716@53941097 338 | main.mp4 339 | #EXTINF:6.0, 340 | #EXT-X-BYTERANGE:555409@54494813 341 | main.mp4\n 342 | """ 343 | -------------------------------------------------------------------------------- /Tests/ParsingTests/AttributeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Parsing 3 | import Types 4 | 5 | class AttributeValueTests: XCTestCase { 6 | 7 | func testFloatingPoint() { 8 | XCTAssertNil(TypeParser.float.run("")?.0) 9 | XCTAssertNil(TypeParser.float.run("A0.1")?.0) 10 | XCTAssertNil(TypeParser.float.run("\n0.1")?.0) 11 | XCTAssertNil(TypeParser.float.run("1")?.0) 12 | XCTAssertNil(TypeParser.float.run("0")?.0) 13 | XCTAssertNil(TypeParser.float.run("-1")?.0) 14 | XCTAssertNil(TypeParser.float.run("-1.1")?.0) 15 | 16 | XCTAssertEqual(TypeParser.float.run("0.1")!.0, 0.1, accuracy: 0.0001) 17 | XCTAssertEqual(TypeParser.float.run("1.1")!.0, 1.1, accuracy: 0.0001) 18 | XCTAssertEqual(TypeParser.float.run("18446744073709551615.18446744073709551615")!.0, 18446744073709551615.18446744073709551615, accuracy: 0.0001) 19 | } 20 | 21 | func testSignedFloatingPoint() { 22 | XCTAssertNil(TypeParser.signedFloat.run("")?.0) 23 | XCTAssertNil(TypeParser.signedFloat.run("A0.1")?.0) 24 | XCTAssertNil(TypeParser.signedFloat.run("\n0.1")?.0) 25 | XCTAssertNil(TypeParser.signedFloat.run("1")?.0) 26 | XCTAssertNil(TypeParser.signedFloat.run("0")?.0) 27 | XCTAssertNil(TypeParser.signedFloat.run("-1")?.0) 28 | 29 | XCTAssertEqual(TypeParser.signedFloat.run("0.1")!.0.rawValue, 0.1, accuracy: 0.0001) 30 | XCTAssertEqual(TypeParser.signedFloat.run("1.1")!.0.rawValue, 1.1, accuracy: 0.0001) 31 | XCTAssertEqual(TypeParser.signedFloat.run("18446744073709551615.18446744073709551615")!.0.rawValue, 18446744073709551615.18446744073709551615, accuracy: 0.0001) 32 | 33 | XCTAssertEqual(TypeParser.signedFloat.run("-0.1")!.0.rawValue, -0.1, accuracy: 0.0001) 34 | XCTAssertEqual(TypeParser.signedFloat.run("-1.1")!.0.rawValue, -1.1, accuracy: 0.0001) 35 | XCTAssertEqual(TypeParser.signedFloat.run("-18446744073709551615.18446744073709551615")!.0.rawValue, -18446744073709551615.18446744073709551615, accuracy: 0.0001) 36 | } 37 | 38 | func testQuotedString() { 39 | XCTAssertNil(TypeParser.quoteString.run("something")) 40 | XCTAssertNil(TypeParser.quoteString.run("something\"")) 41 | XCTAssertNil(TypeParser.quoteString.run("\"something\nelse\"")) 42 | XCTAssertNil(TypeParser.quoteString.run("\"something\relse\"")) 43 | XCTAssertNil(TypeParser.quoteString.run("\"something\r\nelse\"")) 44 | 45 | XCTAssertEqual(TypeParser.quoteString.run("\"\"")?.0, "") 46 | XCTAssertEqual(TypeParser.quoteString.run("\"something\"")?.0, "something") 47 | XCTAssertEqual(TypeParser.quoteString.run("\"something\" else")?.0, "something") 48 | } 49 | 50 | func testEnumeratedString() { 51 | XCTAssertNil(TypeParser.enumString.run("")?.0) 52 | XCTAssertNil(TypeParser.enumString.run("\n")?.0) 53 | XCTAssertEqual(TypeParser.enumString.run("A B")?.0.rawValue, "A") 54 | XCTAssertEqual(TypeParser.enumString.run("A\nB")?.0.rawValue, "A") 55 | XCTAssertEqual(TypeParser.enumString.run("A\rB")?.0.rawValue, "A") 56 | XCTAssertEqual(TypeParser.enumString.run("A\tB")?.0.rawValue, "A") 57 | XCTAssertEqual(TypeParser.enumString.run("A\"B")?.0.rawValue, "A") 58 | XCTAssertEqual(TypeParser.enumString.run("A,B")?.0.rawValue, "A") 59 | 60 | XCTAssertEqual(TypeParser.enumString.run("Abcd efg")?.0.rawValue, "Abcd") 61 | XCTAssertEqual(TypeParser.enumString.run("Abcd\nefg")?.0.rawValue, "Abcd") 62 | XCTAssertEqual(TypeParser.enumString.run("Abcd\refg")?.0.rawValue, "Abcd") 63 | XCTAssertEqual(TypeParser.enumString.run("Abcd\tefg")?.0.rawValue, "Abcd") 64 | XCTAssertEqual(TypeParser.enumString.run("Abcd\"efg")?.0.rawValue, "Abcd") 65 | XCTAssertEqual(TypeParser.enumString.run("Abcd,efg")?.0.rawValue, "Abcd") 66 | } 67 | 68 | func testDecimalResolution() { 69 | XCTAssertNil(decimalResolution.run("1234x")?.0) 70 | XCTAssertNil(decimalResolution.run("1234x")?.0) 71 | 72 | XCTAssertEqual(TypeParser.resolution.run("1x1")?.0, Resolution(width: 1, height: 1)) 73 | XCTAssertEqual(TypeParser.resolution.run("1x2")?.0, Resolution(width: 1, height: 2)) 74 | XCTAssertEqual(TypeParser.resolution.run("1234x5678")?.0, Resolution(width: 1234, height: 5678)) 75 | XCTAssertEqual(TypeParser.resolution.run("1920x1080")?.0, Resolution(width: 1920, height: 1080)) 76 | } 77 | 78 | static var allTests: [(String, (AttributeValueTests) -> () throws -> Void)] { 79 | return [ 80 | ("testFloatingPoint", testFloatingPoint), 81 | ("testSignedFloatingPoint", testSignedFloatingPoint), 82 | ("testQuotedString", testQuotedString), 83 | ("testEnumeratedString", testEnumeratedString), 84 | ("testDecimalResolution", testDecimalResolution) 85 | ] 86 | } 87 | } 88 | 89 | class AttributeListTests: XCTestCase { 90 | 91 | func testSingleIntegerAttribute() { 92 | // Integer 93 | let intAttribute = "NAME=12" 94 | let (parsedIntAL, _) = attributeList.run(intAttribute)! 95 | let expectedIntAL: AttributeList = ["NAME": AttributeValue.decimalInteger(12)] 96 | XCTAssertEqual(parsedIntAL, expectedIntAL) 97 | } 98 | 99 | func testSingleHexAttribute() { 100 | // Hex 101 | let hexAttribute = "NAME=0x12" 102 | let (parsedHexAL, _) = attributeList.run(hexAttribute)! 103 | let expectedHexAL: AttributeList = ["NAME": AttributeValue.hexadecimalSequence(HexadecimalSequence(value: 0x12))] 104 | XCTAssertEqual(parsedHexAL, expectedHexAL) 105 | } 106 | 107 | func testSingleFloatAttribute() { 108 | // Float 109 | let floatAttribute = "NAME=12.123" 110 | let (parsedFloatAL, _) = attributeList.run(floatAttribute)! 111 | let expectedFloatAL: AttributeList = ["NAME": AttributeValue.decimalFloatingPoint(12.123)] 112 | XCTAssertEqual(parsedFloatAL, expectedFloatAL) 113 | } 114 | 115 | func testSingleSignedFloatAttribute() { 116 | // Signed Float 117 | let signedFloatAttribute = "NAME=-12.123" 118 | let (parsedSignedFloatAL, _) = attributeList.run(signedFloatAttribute)! 119 | let expectedSignedFloatAL: AttributeList = ["NAME": AttributeValue.signedDecimalFloatingPoint(SignedFloat(rawValue: -12.123))] 120 | XCTAssertEqual(parsedSignedFloatAL, expectedSignedFloatAL) 121 | } 122 | 123 | func testSingleQuotedStringAttribute() { 124 | // Quoted String 125 | let quotedStringAttribute = "NAME=\"VALUE\"" 126 | let (parsedQuotedStringAL, _) = attributeList.run(quotedStringAttribute)! 127 | let expectedQuotedStringAL: AttributeList = ["NAME": AttributeValue.quotedString("VALUE")] 128 | XCTAssertEqual(parsedQuotedStringAL, expectedQuotedStringAL) 129 | } 130 | 131 | func testSingleStringAttribute() { 132 | // String 133 | let stringAttribute = "NAME=VALUE" 134 | let (parsedStringAL, _) = attributeList.run(stringAttribute)! 135 | let expectedStringAL: AttributeList = ["NAME": AttributeValue.enumeratedString(EnumeratedString(rawValue: "VALUE"))] 136 | XCTAssertEqual(parsedStringAL, expectedStringAL) 137 | } 138 | 139 | func testSingleDecimalResolutionAttribute() { 140 | // Decimal Resolution 141 | let resolutionAttribute = "NAME=12x13" 142 | let (parsedResolutionAL, _) = attributeList.run(resolutionAttribute)! 143 | let expectedResolutionAL: AttributeList = ["NAME": AttributeValue.decimalResolution(Resolution(width: 12, height: 13))] 144 | XCTAssertEqual(parsedResolutionAL, expectedResolutionAL) 145 | } 146 | 147 | func testMultipleAttributes() { 148 | var attributeListStrings: [String] = [] 149 | var expectedAL: AttributeList = [:] 150 | 151 | // Integer 152 | attributeListStrings.append("INTNAME=12") 153 | expectedAL["INTNAME"] = AttributeValue.decimalInteger(12) 154 | 155 | // Hex 156 | attributeListStrings.append("HEXNAME=0x12") 157 | expectedAL["HEXNAME"] = AttributeValue.hexadecimalSequence(HexadecimalSequence(value: 0x12)) 158 | 159 | // Float 160 | attributeListStrings.append("FLOATNAME=12.123") 161 | expectedAL["FLOATNAME"] = AttributeValue.decimalFloatingPoint(12.123) 162 | 163 | // Signed Float 164 | attributeListStrings.append("SIGNEDFLOATNAME=-12.123") 165 | expectedAL["SIGNEDFLOATNAME"] = AttributeValue.signedDecimalFloatingPoint(SignedFloat(rawValue: -12.123)) 166 | 167 | // Quoted String 168 | attributeListStrings.append("QUOTEDNAME=\"VALUE\"") 169 | expectedAL["QUOTEDNAME"] = AttributeValue.quotedString("VALUE") 170 | 171 | // String 172 | attributeListStrings.append("STRINGNAME=VALUE") 173 | expectedAL["STRINGNAME"] = AttributeValue.enumeratedString(EnumeratedString(rawValue: "VALUE")) 174 | 175 | // Decimal Resolution 176 | attributeListStrings.append("RESOLUTIONNAME=12x13") 177 | expectedAL["RESOLUTIONNAME"] = AttributeValue.decimalResolution(Resolution(width: 12, height: 13)) 178 | 179 | let (parsedAL, _) = attributeList.run(attributeListStrings.joined(separator: ","))! 180 | 181 | XCTAssertEqual(expectedAL, parsedAL) 182 | } 183 | 184 | static var allTests: [(String, (AttributeListTests) -> () throws -> Void)] { 185 | return [ 186 | ("testSingleIntegerAttribute", testSingleIntegerAttribute), 187 | ("testSingleHexAttribute", testSingleHexAttribute), 188 | ("testSingleFloatAttribute", testSingleFloatAttribute), 189 | ("testSingleSignedFloatAttribute", testSingleSignedFloatAttribute), 190 | ("testSingleQuotedStringAttribute", testSingleQuotedStringAttribute), 191 | ("testSingleStringAttribute", testSingleStringAttribute), 192 | ("testSingleDecimalResolutionAttribute", testSingleDecimalResolutionAttribute), 193 | 194 | ("testMultipleAttributes", testMultipleAttributes) 195 | ] 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /Sources/Parsing/CoreInitializers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreInitializers.swift 3 | // HLSCore 4 | // 5 | // Created by Fabian Canas on 9/13/16. 6 | // Copyright © 2016 Fabian Canas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Types 11 | 12 | // Initialize core HLS components built from parser results 13 | 14 | /// EnumeratedString Extension for StartIndicator 15 | fileprivate extension EnumeratedString { 16 | static let yes = EnumeratedString("YES") 17 | static let no = EnumeratedString("NO") 18 | } 19 | 20 | extension StartIndicator { 21 | 22 | static private let timeOffsetKey = "TIME-OFFSET" 23 | static private let preciseKey = "PRECISE" 24 | 25 | init?(attributes: AttributeList) { 26 | 27 | var timeOffset: TimeInterval? 28 | var precise: Bool? 29 | 30 | for attribute in attributes { 31 | switch attribute { 32 | case let (StartIndicator.timeOffsetKey, .decimalFloatingPoint(time)): 33 | timeOffset = time 34 | case let (StartIndicator.preciseKey, .enumeratedString(p)): 35 | switch p { 36 | case .yes: 37 | precise = true 38 | case .no: 39 | precise = false 40 | default: 41 | assert(false, "PRECISE attribute in EXT-X-START must be YES or NO") 42 | precise = nil 43 | } 44 | default: 45 | return nil 46 | } 47 | } 48 | 49 | guard let offset = timeOffset else { 50 | return nil 51 | } 52 | 53 | if let precise = precise { 54 | self.init(at: offset, preciseStart: precise) 55 | } else { 56 | // Let initializer decide default value of non-required parameter 57 | self.init(at: offset) 58 | } 59 | } 60 | } 61 | 62 | extension DecryptionKey { 63 | 64 | static private let methodKey = "METHOD" 65 | static private let uriKey = "URI" 66 | static private let initializationVectorKey = "IV" 67 | static private let keyFormatKey = "KEYFORMAT" 68 | static private let keyFormatVersionsKey = "KEYFORMATVERSIONS" 69 | 70 | init?(attributes: AttributeList) { 71 | 72 | enum EncryptionMethodString: String { 73 | case None = "NONE" 74 | case AES128 = "AES-128" 75 | case SampleAES = "SAMPLE-AES" 76 | } 77 | 78 | var methodVar: EncryptionMethodString? 79 | var uriVar: URL? 80 | var initializationVectorVar: InitializationVector? 81 | var keyFormatVar: String = IdentityDecryptionKeyFormat 82 | var keyFormatVersionsVar: [Int]? 83 | 84 | for attribute in attributes { 85 | switch attribute { 86 | case let (DecryptionKey.methodKey, .enumeratedString(m)): 87 | guard let m = EncryptionMethodString(rawValue: m.rawValue) else { 88 | // TODO: Logging - Unrecognized encryption method 89 | return nil 90 | } 91 | methodVar = m 92 | case let (DecryptionKey.uriKey, .quotedString(s)): 93 | guard let u = URL(string: s) else { 94 | return nil 95 | } 96 | uriVar = u 97 | case let (DecryptionKey.initializationVectorKey, .hexadecimalSequence(h)): 98 | // TODO: Extend HexadecimalSequence to encode 128-bit numbers 99 | initializationVectorVar = InitializationVector(low: UInt64(h.value), high: UInt64(h.value)) 100 | case let (DecryptionKey.keyFormatKey, .quotedString(s)): 101 | keyFormatVar = s 102 | case let (DecryptionKey.keyFormatVersionsKey, .quotedString(s)): 103 | keyFormatVersionsVar = s.components(separatedBy: "/").compactMap({ (string) -> Int? in 104 | Int(string) 105 | }) 106 | default: 107 | break 108 | } 109 | } 110 | 111 | guard let method = methodVar else { 112 | return nil 113 | } 114 | 115 | let methodOut: EncryptionMethod 116 | switch method { 117 | case .None: 118 | methodOut = .None 119 | case .AES128: 120 | guard let uri = uriVar else { 121 | return nil 122 | } 123 | methodOut = .AES128(uri) 124 | case .SampleAES: 125 | guard let uri = uriVar else { 126 | return nil 127 | } 128 | methodOut = .SampleAES(uri) 129 | } 130 | 131 | self.init(method: methodOut, 132 | initializationVector: initializationVectorVar, 133 | keyFormat: keyFormatVar, 134 | keyFormatVersions: keyFormatVersionsVar) 135 | } 136 | } 137 | 138 | extension StreamInfo { 139 | 140 | fileprivate struct AttributeKey { 141 | static fileprivate let Bandwidth = "BANDWIDTH" 142 | static fileprivate let AverageBandwidth = "AVERAGE-BANDWIDTH" 143 | static fileprivate let Codecs = "CODECS" 144 | static fileprivate let Resolution = "RESOLUTION" 145 | static fileprivate let FrameRate = "FRAME-RATE" 146 | static fileprivate let Audio = "AUDIO" 147 | static fileprivate let Video = "VIDEO" 148 | static fileprivate let Subtitles = "SUBTITLES" 149 | static fileprivate let ClosedCaptions = "CLOSED-CAPTIONS" 150 | } 151 | 152 | init?(attributes: AttributeList, uri: URL) { 153 | 154 | var bandwidthVar: Bitrate? 155 | var averageBandwidthVar: Bitrate? 156 | var codecsVar: [Codec] = [] 157 | var resolutionVar: Resolution? 158 | var framerateVar: Double? 159 | 160 | for attribute in attributes { 161 | switch attribute { 162 | case let (AttributeKey.Bandwidth, .decimalInteger(i)): 163 | bandwidthVar = Bitrate(UInt64(i)) 164 | case let (AttributeKey.AverageBandwidth, .decimalInteger(i)): 165 | averageBandwidthVar = Bitrate(UInt64(i)) 166 | case let (AttributeKey.Codecs, .quotedString(s)): 167 | codecsVar = s.components(separatedBy: ",").map { Codec(rawValue: $0) } 168 | case let (AttributeKey.Resolution, .decimalResolution(r)): 169 | resolutionVar = r 170 | case let (AttributeKey.FrameRate, .decimalFloatingPoint(f)): 171 | framerateVar = f 172 | default: 173 | break 174 | } 175 | } 176 | 177 | guard let b = bandwidthVar else { 178 | return nil 179 | } 180 | 181 | self.init(bandwidth: b, 182 | averageBandwidth: averageBandwidthVar, 183 | codecs: codecsVar, 184 | resolution: resolutionVar, 185 | frameRate: framerateVar, 186 | uri: uri) 187 | } 188 | } 189 | 190 | extension MediaInitializationSection { 191 | 192 | fileprivate struct AttributeKey { 193 | static fileprivate let URI = "URI" 194 | static fileprivate let ByteRange = "BYTERANGE" 195 | } 196 | 197 | init?(attributes: AttributeList) { 198 | 199 | var urlVar: URL? 200 | var byteRangeVar: CountableClosedRange? 201 | 202 | for attribute in attributes { 203 | switch attribute { 204 | case let (AttributeKey.URI, .quotedString(urlString)): 205 | urlVar = URL(string: urlString) 206 | case let (AttributeKey.ByteRange, .quotedString(byteRangeString)): 207 | let parseResults = TypeParser.byteRange.run(byteRangeString) 208 | guard let range = parseResults?.0 else { 209 | return nil 210 | } 211 | byteRangeVar = range 212 | default: 213 | break 214 | } 215 | } 216 | 217 | guard let url = urlVar else { 218 | return nil 219 | } 220 | 221 | self.init(uri: url, byteRange: byteRangeVar) 222 | } 223 | 224 | } 225 | 226 | extension Rendition { 227 | 228 | fileprivate struct AttributeKey { 229 | static fileprivate let type = "TYPE" 230 | static fileprivate let uri = "URI" 231 | static fileprivate let groupID = "GROUP-ID" 232 | static fileprivate let language = "LANGUAGE" 233 | static fileprivate let associatedLanguage = "ASSOC-LANGUAGE" 234 | static fileprivate let name = "NAME" 235 | static fileprivate let `default` = "DEFAULT" 236 | static fileprivate let autoselect = "AUTOSELECT" 237 | static fileprivate let forced = "FORCED" 238 | static fileprivate let instreamID = "INSTREAM-ID" 239 | static fileprivate let characteristics = "CHARACTERISTICS" 240 | } 241 | 242 | init?(attributes: AttributeList) { 243 | 244 | var type: MediaType? 245 | var uri: URL? 246 | var groupID: String? 247 | var language: Language? 248 | var associatedLanguage: Language? 249 | var name: String? 250 | var defaultRendition = false 251 | var forced = false 252 | 253 | for attribute in attributes { 254 | switch attribute { 255 | case let (AttributeKey.type, .enumeratedString(typeString)): 256 | type = MediaType(rawValue: typeString.rawValue) 257 | case let (AttributeKey.uri, .quotedString(uriString)): 258 | uri = URL(string: uriString) 259 | case let (AttributeKey.groupID, .quotedString(renditionGroup)): 260 | groupID = renditionGroup 261 | case let (AttributeKey.language, .quotedString(languageString)): 262 | language = Language(languageString) 263 | case let (AttributeKey.associatedLanguage, .quotedString(associatedLanguageString)): 264 | associatedLanguage = Language(associatedLanguageString) 265 | case let (AttributeKey.name, .quotedString(nameString)): 266 | name = nameString 267 | case let (AttributeKey.default, .enumeratedString(boolString)): 268 | guard let isDefault = ["YES": true, "NO": false][boolString.rawValue] else { 269 | return nil 270 | } 271 | defaultRendition = isDefault 272 | case let (AttributeKey.autoselect, .enumeratedString(boolString)): 273 | guard let isForced = ["YES": true, "NO": false][boolString.rawValue] else { 274 | return nil 275 | } 276 | forced = isForced 277 | default: 278 | break 279 | } 280 | } 281 | 282 | guard let mediaType = type, let group = groupID, let renditionName = name else { 283 | return nil 284 | } 285 | 286 | self.init(mediaType: mediaType, 287 | uri: uri, 288 | groupID: group, 289 | language: language, 290 | associatedLanguage: associatedLanguage, 291 | name: renditionName, 292 | defaultRendition: defaultRendition, 293 | forced: forced) 294 | 295 | } 296 | 297 | } 298 | --------------------------------------------------------------------------------