diff --git a/README.md b/README.md index 4ffef44..6d33088 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The [Swift Package Manager](https://swift.org/package-manager/) is a tool for ma ```swift dependencies: [ - .package(url: "https://github.com/omaralbeik/M3UKit.git", from: "0.7.0") + .package(url: "https://github.com/omaralbeik/M3UKit.git", from: "0.8.0") ] ``` @@ -140,7 +140,7 @@ $ swift build To integrate M3UKit into your Xcode project using [CocoaPods](https://cocoapods.org), specify it in your Podfile: ```rb -pod 'M3UKit', :git => 'https://github.com/omaralbeik/M3UKit.git', :tag => '0.7.0' +pod 'M3UKit', :git => 'https://github.com/omaralbeik/M3UKit.git', :tag => '0.8.0' ``` ### Carthage @@ -148,7 +148,7 @@ pod 'M3UKit', :git => 'https://github.com/omaralbeik/M3UKit.git', :tag => '0.7.0 To integrate M3UKit into your Xcode project using [Carthage](https://github.com/Carthage/Carthage), specify it in your Cartfile: ``` -github "omaralbeik/M3UKit" ~> 0.7.0 +github "omaralbeik/M3UKit" ~> 0.8.0 ``` ### Manually diff --git a/Sources/M3UKit/Models/Media.swift b/Sources/M3UKit/Models/Media.swift deleted file mode 100644 index 893051b..0000000 --- a/Sources/M3UKit/Models/Media.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -extension Playlist { - /// Object representing a media. - public struct Media: Equatable, Hashable, Codable { - typealias Metadata = ( - duration: Int, - attributes: Attributes, - name: String - ) - - init( - metadata: Metadata, - kind: Kind, - url: URL - ) { - self.init( - duration: metadata.duration, - attributes: metadata.attributes, - kind: kind, - name: metadata.name, - url: url - ) - } - - /// Create a new media object. - /// - Parameters: - /// - duration: duration. - /// - attributes: attributes. - /// - kind: kind. - /// - name: name. - /// - url: url. - public init( - duration: Int, - attributes: Attributes, - kind: Kind, - name: String, - url: URL - ) { - self.duration = duration - self.attributes = attributes - self.kind = kind - self.name = name - self.url = url - } - - /// Duration, Usually -1 for live stream content. - public var duration: Int - - /// Attributes. - public var attributes: Attributes - - /// Kind. - public var kind: Kind - - /// Media name. - public var name: String - - /// Media URL. - public var url: URL - } -} diff --git a/Sources/M3UKit/Models/MediaAttributes.swift b/Sources/M3UKit/Models/MediaAttributes.swift deleted file mode 100644 index b671f34..0000000 --- a/Sources/M3UKit/Models/MediaAttributes.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -extension Playlist.Media { - /// Object representing attributes for a media. - public struct Attributes: Equatable, Hashable, Codable { - /// Create a new attributes object. - /// - Parameters: - /// - id: id. - /// - name: name. - /// - country: country. - /// - language: language. - /// - logo: logo. - /// - channelNumber: channel number. - /// - shift: shift. - /// - groupTitle: group title. - /// - seasonNumber: Season number (for TV shows). - /// - episodeNumber: Episode number (for TV shows). - public init( - id: String? = nil, - name: String? = nil, - country: String? = nil, - language: String? = nil, - logo: String? = nil, - channelNumber: String? = nil, - shift: String? = nil, - groupTitle: String? = nil, - seasonNumber: Int? = nil, - episodeNumber: Int? = nil - ) { - self.id = id - self.name = name - self.country = country - self.language = language - self.logo = logo - self.channelNumber = channelNumber - self.shift = shift - self.groupTitle = groupTitle - self.seasonNumber = seasonNumber - self.episodeNumber = episodeNumber - } - - /// tvg-id. - public var id: String? - - /// tvg-name. - public var name: String? - - /// tvg-country. - public var country: String? - - /// tvg-language. - public var language: String? - - /// tvg-logo. - public var logo: String? - - /// tvg-chno. - public var channelNumber: String? - - /// tvg-shift. - public var shift: String? - - /// group-title. - public var groupTitle: String? - - /// Season number (for TV shows). - public var seasonNumber: Int? - - /// Episode number (for TV shows). - public var episodeNumber: Int? - } -} diff --git a/Sources/M3UKit/Models/MediaKind.swift b/Sources/M3UKit/Models/MediaKind.swift deleted file mode 100644 index b68a7c5..0000000 --- a/Sources/M3UKit/Models/MediaKind.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -extension Playlist.Media { - /// Enum representing media kind. - public enum Kind: String, Equatable, Hashable, Codable { - case movie - case series - case live - case unknown - } -} diff --git a/Sources/M3UKit/Models/Playlist.swift b/Sources/M3UKit/Models/Playlist.swift deleted file mode 100644 index 5b2dad1..0000000 --- a/Sources/M3UKit/Models/Playlist.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -/// Object representing a playlist containing media items. -public struct Playlist: Equatable, Hashable, Codable { - /// Create a playlist. - /// - Parameter medias: medias. - public init(medias: [Media]) { - self.medias = medias - } - - /// Medias. - public var medias: [Media] -} diff --git a/Sources/M3UKit/Parsers/MediaAttributesParser.swift b/Sources/M3UKit/Parsers/MediaAttributesParser.swift deleted file mode 100644 index 2a798be..0000000 --- a/Sources/M3UKit/Parsers/MediaAttributesParser.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -final class MediaAttributesParser: Parser { - typealias Attributes = Playlist.Media.Attributes - - func parse(_ input: String) -> Attributes { - var attributes = Attributes() - if let id = idRegex.firstMatch(in: input) { - attributes.id = id - } - if let name = nameRegex.firstMatch(in: input) { - let show = seasonEpisodeParser.parse(name) - attributes.name = show.name - attributes.seasonNumber = show.se?.s - attributes.episodeNumber = show.se?.e - } - if let country = countryRegex.firstMatch(in: input) { - attributes.country = country - } - if let language = languageRegex.firstMatch(in: input) { - attributes.language = language - } - if let logo = logoRegex.firstMatch(in: input) { - attributes.logo = logo - } - if let channelNumber = channelNumberRegex.firstMatch(in: input) { - attributes.channelNumber = channelNumber - } - if let shift = shiftRegex.firstMatch(in: input) { - attributes.shift = shift - } - if let groupTitle = groupTitleRegex.firstMatch(in: input) { - attributes.groupTitle = groupTitle - } - return attributes - } - - let seasonEpisodeParser = SeasonEpisodeParser() - let idRegex: RegularExpression = #"tvg-id=\"(.?|.+?)\""# - let nameRegex: RegularExpression = #"tvg-name=\"(.?|.+?)\""# - let countryRegex: RegularExpression = #"tvg-country=\"(.?|.+?)\""# - let languageRegex: RegularExpression = #"tvg-language=\"(.?|.+?)\""# - let logoRegex: RegularExpression = #"tvg-logo=\"(.?|.+?)\""# - let channelNumberRegex: RegularExpression = #"tvg-chno=\"(.?|.+?)\""# - let shiftRegex: RegularExpression = #"tvg-shift=\"(.?|.+?)\""# - let groupTitleRegex: RegularExpression = #"group-title=\"(.?|.+?)\""# -} diff --git a/Sources/M3UKit/Parsers/MediaKindParser.swift b/Sources/M3UKit/Parsers/MediaKindParser.swift deleted file mode 100644 index e515666..0000000 --- a/Sources/M3UKit/Parsers/MediaKindParser.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -final class MediaKindParser: Parser { - func parse(_ input: URL) -> Playlist.Media.Kind { - let string = input.absoluteString - if seriesRegex.numberOfMatches(source: string) == 1 { - return .series - } - if moviesRegex.numberOfMatches(source: string) == 1 { - return .movie - } - if liveRegex.numberOfMatches(source: string) == 1 { - return .live - } - return .unknown - } - - let moviesRegex: RegularExpression = #"\/movies\/"# - let seriesRegex: RegularExpression = #"\/series\/"# - let liveRegex: RegularExpression = #"\/live\/"# -} diff --git a/Sources/M3UKit/Parsers/MediaMetadataParser.swift b/Sources/M3UKit/Parsers/MediaMetadataParser.swift deleted file mode 100644 index f7c71b6..0000000 --- a/Sources/M3UKit/Parsers/MediaMetadataParser.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -final class MediaMetadataParser: Parser { - enum ParsingError: LocalizedError { - case missingDuration(Int, String) - - var errorDescription: String? { - switch self { - case .missingDuration(let lineNumber, let rawString): - return "Line \(lineNumber): Missing duration in line \"\(rawString)\"" - } - } - } - - func parse(_ input: (line: Int, rawString: String)) throws -> Playlist.Media.Metadata { - let duration = try extractDuration(input) - let attributes = attributesParser.parse(input.rawString) - let name = seasonEpisodeParser.parse(extractName(input.rawString)).name - return (duration, attributes, name) - } - - func isInfoLine(_ input: String) -> Bool { - return input.starts(with: "#EXTINF:") - } - - func extractDuration(_ input: (line: Int, rawString: String)) throws -> Int { - guard - let match = durationRegex.firstMatch(in: input.rawString), - let duration = Int(match) - else { - throw ParsingError.missingDuration(input.line, input.rawString) - } - return duration - } - - func extractName(_ input: String) -> String { - return nameRegex.firstMatch(in: input) ?? "" - } - - let seasonEpisodeParser = SeasonEpisodeParser() - let attributesParser = MediaAttributesParser() - let durationRegex: RegularExpression = #"#EXTINF:(\-*\d+)"# - let nameRegex: RegularExpression = #".*,(.+?)$"# -} diff --git a/Sources/M3UKit/Parsers/Parser.swift b/Sources/M3UKit/Parsers/Parser.swift deleted file mode 100644 index 0134131..0000000 --- a/Sources/M3UKit/Parsers/Parser.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -protocol Parser { - associatedtype Input - associatedtype Output - - func parse(_ input: Input) throws -> Output -} diff --git a/Sources/M3UKit/Parsers/PlaylistParser.swift b/Sources/M3UKit/Parsers/PlaylistParser.swift deleted file mode 100644 index 23c65be..0000000 --- a/Sources/M3UKit/Parsers/PlaylistParser.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import Foundation - -/// A class to parse `Playlist` objects from a `PlaylistSource`. -public final class PlaylistParser: Parser { - enum ParsingError: LocalizedError { - case invalidSource - - var errorDescription: String? { - "The playlist is invalid" - } - } - - /// Create a new parser. - public init() {} - - /// Parse a playlist. - /// - Parameter input: source. - /// - Returns: playlist. - public func parse(_ input: PlaylistSource) throws -> Playlist { - let rawString = try extractRawString(from: input) - - var medias: [Playlist.Media] = [] - - let metadataParser = MediaMetadataParser() - let kindParser = MediaKindParser() - var lastMetadataLine: String? - var lastURL: URL? - var mediaMetadataParsingError: Error? - var lineNumber = 0 - - rawString.enumerateLines { line, stop in - if metadataParser.isInfoLine(line) { - lastMetadataLine = line - } else if let url = URL(string: line) { - lastURL = url - } - - if let metadataLine = lastMetadataLine, let url = lastURL { - do { - let metadata = try metadataParser.parse((lineNumber, metadataLine)) - let kind = kindParser.parse(url) - medias.append(.init(metadata: metadata, kind: kind, url: url)) - lastMetadataLine = nil - lastURL = nil - } catch { - mediaMetadataParsingError = error - stop = true - } - } - - lineNumber += 1 - } - - if let error = mediaMetadataParsingError { - throw error - } - - return Playlist(medias: medias) - } - - /// Walk over a playlist and return its medias one-by-one. - /// - Parameters: - /// - input: source. - /// - handler: Handler to be called with the parsed medias. - public func walk( - _ input: PlaylistSource, - handler: @escaping (Playlist.Media) -> Void - ) throws { - let rawString = try extractRawString(from: input) - - let metadataParser = MediaMetadataParser() - let kindParser = MediaKindParser() - var lastMetadataLine: String? - var lastURL: URL? - var mediaMetadataParsingError: Error? - var lineNumber = 0 - - rawString.enumerateLines { line, stop in - if metadataParser.isInfoLine(line) { - lastMetadataLine = line - } else if let url = URL(string: line) { - lastURL = url - } - - if let metadataLine = lastMetadataLine, let url = lastURL { - do { - let metadata = try metadataParser.parse((lineNumber, metadataLine)) - let kind = kindParser.parse(url) - handler(.init(metadata: metadata, kind: kind, url: url)) - lastMetadataLine = nil - lastURL = nil - } catch { - mediaMetadataParsingError = error - stop = true - } - } - lineNumber += 1 - } - - if let error = mediaMetadataParsingError { - throw error - } - } - - /// Parse a playlist on a queue with a completion handler. - /// - Parameters: - /// - input: source. - /// - processingQueue: queue to perform parsing on. Defaults to `.global(qos: .background)` - /// - callbackQueue: queue to call callback on. Defaults to `.main` - /// - completion: completion handler to call with the result. - public func parse( - _ input: PlaylistSource, - processingQueue: DispatchQueue = .global(qos: .background), - callbackQueue: DispatchQueue = .main, - completion: @escaping (Result) -> Void - ) { - processingQueue.async { - do { - let playlist = try self.parse(input) - callbackQueue.async { - completion(.success(playlist)) - } - } catch { - callbackQueue.async { - completion(.failure(error)) - } - } - } - } - - @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) - /// Parse a playlist. - /// - Parameter input: source. - /// - Parameter priority: Processing task priority. Defaults to `.background` - /// - Returns: playlist. - public func parse( - _ input: PlaylistSource, - priority: TaskPriority = .background - ) async throws -> Playlist { - let processingTask = Task(priority: priority) { () -> Playlist in - let playlist = try self.parse(input) - return playlist - } - return try await processingTask.value - } - - // MARK: - Helpers - - private func extractRawString(from input: PlaylistSource) throws -> String { - let filePrefix = "#EXTM3U" - guard var rawString = input.rawString else { - throw ParsingError.invalidSource - } - guard rawString.starts(with: filePrefix) else { - throw ParsingError.invalidSource - } - rawString.removeFirst(filePrefix.count) - return rawString - } -} diff --git a/Sources/M3UKit/Parsers/SeasonEpisodeParser.swift b/Sources/M3UKit/Parsers/SeasonEpisodeParser.swift deleted file mode 100644 index 5688361..0000000 --- a/Sources/M3UKit/Parsers/SeasonEpisodeParser.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// M3UKit -// -// Copyright (c) 2022 Omar Albeik -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -final class SeasonEpisodeParser: Parser { - typealias Show = (name: String, se: (s: Int, e: Int)?) - - func parse(_ input: String) -> Show { - let ranges = seasonEpisodeRegex.matchingRanges(in: input) - guard - ranges.count == 3, - let s = Int(input[ranges[1]]), - let e = Int(input[ranges[2]]) - else { - return (name: input, se: nil) - } - var name = input - name.removeSubrange(ranges[0]) - return (name: name, se: (s, e)) - } - - let seasonEpisodeRegex: RegularExpression = #" S(\d+) E(\d+)"# -} diff --git a/Sources/M3UKit/Playlist.swift b/Sources/M3UKit/Playlist.swift new file mode 100644 index 0000000..04c7d67 --- /dev/null +++ b/Sources/M3UKit/Playlist.swift @@ -0,0 +1,174 @@ +// +// M3UKit +// +// Copyright (c) 2022 Omar Albeik +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +/// Object representing a playlist containing media items. +public struct Playlist: Equatable, Hashable, Codable { + + /// Object representing a media. + public struct Media: Equatable, Hashable, Codable { + + /// Object representing attributes for a media. + public struct Attributes: Equatable, Hashable, Codable { + /// Create a new attributes object. + /// - Parameters: + /// - id: id. + /// - name: name. + /// - country: country. + /// - language: language. + /// - logo: logo. + /// - channelNumber: channel number. + /// - shift: shift. + /// - groupTitle: group title. + /// - seasonNumber: Season number (for TV shows). + /// - episodeNumber: Episode number (for TV shows). + public init( + id: String? = nil, + name: String? = nil, + country: String? = nil, + language: String? = nil, + logo: String? = nil, + channelNumber: String? = nil, + shift: String? = nil, + groupTitle: String? = nil, + seasonNumber: Int? = nil, + episodeNumber: Int? = nil + ) { + self.id = id + self.name = name + self.country = country + self.language = language + self.logo = logo + self.channelNumber = channelNumber + self.shift = shift + self.groupTitle = groupTitle + self.seasonNumber = seasonNumber + self.episodeNumber = episodeNumber + } + + /// tvg-id. + public var id: String? + + /// tvg-name. + public var name: String? + + /// tvg-country. + public var country: String? + + /// tvg-language. + public var language: String? + + /// tvg-logo. + public var logo: String? + + /// tvg-chno. + public var channelNumber: String? + + /// tvg-shift. + public var shift: String? + + /// group-title. + public var groupTitle: String? + + /// Season number (for TV shows). + public var seasonNumber: Int? + + /// Episode number (for TV shows). + public var episodeNumber: Int? + } + + /// Enum representing media kind. + public enum Kind: String, Equatable, Hashable, Codable { + case movie + case series + case live + case unknown + } + + internal typealias Metadata = ( + duration: Int, + attributes: Attributes, + name: String + ) + + internal init( + metadata: Metadata, + kind: Kind, + url: URL + ) { + self.init( + duration: metadata.duration, + attributes: metadata.attributes, + kind: kind, + name: metadata.name, + url: url + ) + } + + /// Create a new media object. + /// - Parameters: + /// - duration: duration. + /// - attributes: attributes. + /// - kind: kind. + /// - name: name. + /// - url: url. + public init( + duration: Int, + attributes: Attributes, + kind: Kind, + name: String, + url: URL + ) { + self.duration = duration + self.attributes = attributes + self.kind = kind + self.name = name + self.url = url + } + + /// Duration, Usually -1 for live stream content. + public var duration: Int + + /// Attributes. + public var attributes: Attributes + + /// Kind. + public var kind: Kind + + /// Media name. + public var name: String + + /// Media URL. + public var url: URL + } + + /// Create a playlist. + /// - Parameter medias: medias. + public init(medias: [Media]) { + self.medias = medias + } + + /// Medias. + public var medias: [Media] +} diff --git a/Sources/M3UKit/PlaylistParser.swift b/Sources/M3UKit/PlaylistParser.swift new file mode 100644 index 0000000..f9fb428 --- /dev/null +++ b/Sources/M3UKit/PlaylistParser.swift @@ -0,0 +1,325 @@ +// +// M3UKit +// +// Copyright (c) 2022 Omar Albeik +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +/// A class to parse `Playlist` objects from a `PlaylistSource`. +public final class PlaylistParser { + + /// Playlist parser options + public struct Options: OptionSet { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// Remove season number and episode number "S--E--" from the name of media. + public static let removeSeriesInfoFromText = Options(rawValue: 1 << 0) + + /// All available options. + public static let all: Options = [.removeSeriesInfoFromText] + } + + /// Parser options. + public let options: Options + + /// Create a new parser. + /// - Parameter options: Parser options, defaults to .all + public init(options: Options = []) { + self.options = options + } + + /// Parse a playlist. + /// - Parameter input: source. + /// - Returns: playlist. + public func parse(_ input: PlaylistSource) throws -> Playlist { + let rawString = try extractRawString(from: input) + + var medias: [Playlist.Media] = [] + + var lastMetadataLine: String? + var lastURL: URL? + var mediaMetadataParsingError: Error? + var lineNumber = 0 + + rawString.enumerateLines { [weak self] line, stop in + guard let self else { + stop = true + return + } + + if self.isInfoLine(line) { + lastMetadataLine = line + } else if let url = URL(string: line) { + lastURL = url + } + + if let metadataLine = lastMetadataLine, let url = lastURL { + do { + let metadata = try self.parseMetadata((lineNumber, metadataLine)) + let kind = self.parseMediaKind(url) + medias.append(.init(metadata: metadata, kind: kind, url: url)) + lastMetadataLine = nil + lastURL = nil + } catch { + mediaMetadataParsingError = error + stop = true + } + } + + lineNumber += 1 + } + + if let error = mediaMetadataParsingError { + throw error + } + + return Playlist(medias: medias) + } + + /// Walk over a playlist and return its medias one-by-one. + /// - Parameters: + /// - input: source. + /// - handler: Handler to be called with the parsed medias. + public func walk( + _ input: PlaylistSource, + handler: @escaping (Playlist.Media) -> Void + ) throws { + let rawString = try extractRawString(from: input) + + var lastMetadataLine: String? + var lastURL: URL? + var mediaMetadataParsingError: Error? + var lineNumber = 0 + + rawString.enumerateLines { [weak self] line, stop in + guard let self else { + stop = true + return + } + + if self.isInfoLine(line) { + lastMetadataLine = line + } else if let url = URL(string: line) { + lastURL = url + } + + if let metadataLine = lastMetadataLine, let url = lastURL { + do { + let metadata = try self.parseMetadata((lineNumber, metadataLine)) + let kind = self.parseMediaKind(url) + handler(.init(metadata: metadata, kind: kind, url: url)) + lastMetadataLine = nil + lastURL = nil + } catch { + mediaMetadataParsingError = error + stop = true + } + } + lineNumber += 1 + } + + if let error = mediaMetadataParsingError { + throw error + } + } + + /// Parse a playlist on a queue with a completion handler. + /// - Parameters: + /// - input: source. + /// - processingQueue: queue to perform parsing on. Defaults to `.global(qos: .background)` + /// - callbackQueue: queue to call callback on. Defaults to `.main` + /// - completion: completion handler to call with the result. + public func parse( + _ input: PlaylistSource, + processingQueue: DispatchQueue = .global(qos: .background), + callbackQueue: DispatchQueue = .main, + completion: @escaping (Result) -> Void + ) { + processingQueue.async { + do { + let playlist = try self.parse(input) + callbackQueue.async { + completion(.success(playlist)) + } + } catch { + callbackQueue.async { + completion(.failure(error)) + } + } + } + } + + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + /// Parse a playlist. + /// - Parameter input: source. + /// - Parameter priority: Processing task priority. Defaults to `.background` + /// - Returns: playlist. + public func parse( + _ input: PlaylistSource, + priority: TaskPriority = .background + ) async throws -> Playlist { + let processingTask = Task(priority: priority) { + try self.parse(input) + } + return try await processingTask.value + } + + // MARK: - Helpers + + internal func extractRawString(from input: PlaylistSource) throws -> String { + let filePrefix = "#EXTM3U" + guard var rawString = input.rawString else { + throw ParsingError.invalidSource + } + guard rawString.starts(with: filePrefix) else { + throw ParsingError.invalidSource + } + rawString.removeFirst(filePrefix.count) + return rawString + } + + internal enum ParsingError: LocalizedError { + case invalidSource + case missingDuration(Int, String) + + internal var errorDescription: String? { + switch self { + case .invalidSource: + return "The playlist is invalid" + case .missingDuration(let line, let raw): + return "Line \(line): Missing duration in line \"\(raw)\"" + } + } + } + + internal typealias Show = (name: String, se: (s: Int, e: Int)?) + + internal func parseMetadata(_ input: (line: Int, rawString: String)) throws -> Playlist.Media.Metadata { + let duration = try extractDuration(input) + let attributes = parseAttributes(input.rawString) + let name = parseSeasonEpisode(extractName(input.rawString)).name + return (duration, attributes, name) + } + + internal func isInfoLine(_ input: String) -> Bool { + return input.starts(with: "#EXTINF:") + } + + internal func extractDuration(_ input: (line: Int, rawString: String)) throws -> Int { + guard + let match = durationRegex.firstMatch(in: input.rawString), + let duration = Int(match) + else { + throw ParsingError.missingDuration(input.line, input.rawString) + } + return duration + } + + internal func extractName(_ input: String) -> String { + return nameRegex.firstMatch(in: input) ?? "" + } + + internal func parseMediaKind(_ input: URL) -> Playlist.Media.Kind { + let string = input.absoluteString + if mediaKindMSeriesRegex.numberOfMatches(source: string) == 1 { + return .series + } + if mediaKindMoviesRegex.numberOfMatches(source: string) == 1 { + return .movie + } + if mediaKindMLiveRegex.numberOfMatches(source: string) == 1 { + return .live + } + return .unknown + } + + internal func parseAttributes(_ input: String) -> Playlist.Media.Attributes { + var attributes = Playlist.Media.Attributes() + if let id = attributesIdRegex.firstMatch(in: input) { + attributes.id = id + } + if let name = attributesNameRegex.firstMatch(in: input) { + let show = parseSeasonEpisode(name) + attributes.name = show.name + attributes.seasonNumber = show.se?.s + attributes.episodeNumber = show.se?.e + } + if let country = attributesCountryRegex.firstMatch(in: input) { + attributes.country = country + } + if let language = attributesLanguageRegex.firstMatch(in: input) { + attributes.language = language + } + if let logo = attributesLogoRegex.firstMatch(in: input) { + attributes.logo = logo + } + if let channelNumber = attributesChannelNumberRegex.firstMatch(in: input) { + attributes.channelNumber = channelNumber + } + if let shift = attributesShiftRegex.firstMatch(in: input) { + attributes.shift = shift + } + if let groupTitle = attributesGroupTitleRegex.firstMatch(in: input) { + attributes.groupTitle = groupTitle + } + return attributes + } + + internal func parseSeasonEpisode(_ input: String) -> Show { + let ranges = seasonEpisodeRegex.matchingRanges(in: input) + guard + ranges.count == 3, + let s = Int(input[ranges[1]]), + let e = Int(input[ranges[2]]) + else { + return (name: input, se: nil) + } + var name = input + if options.contains(.removeSeriesInfoFromText) { + name.removeSubrange(ranges[0]) + } + return (name: name, se: (s, e)) + } + + // MARK: - Regex + + internal let durationRegex: RegularExpression = #"#EXTINF:(\-*\d+)"# + internal let nameRegex: RegularExpression = #".*,(.+?)$"# + + internal let mediaKindMoviesRegex: RegularExpression = #"\/movies\/"# + internal let mediaKindMSeriesRegex: RegularExpression = #"\/series\/"# + internal let mediaKindMLiveRegex: RegularExpression = #"\/live\/"# + + internal let seasonEpisodeRegex: RegularExpression = #" S(\d+) E(\d+)"# + + internal let attributesIdRegex: RegularExpression = #"tvg-id=\"(.?|.+?)\""# + internal let attributesNameRegex: RegularExpression = #"tvg-name=\"(.?|.+?)\""# + internal let attributesCountryRegex: RegularExpression = #"tvg-country=\"(.?|.+?)\""# + internal let attributesLanguageRegex: RegularExpression = #"tvg-language=\"(.?|.+?)\""# + internal let attributesLogoRegex: RegularExpression = #"tvg-logo=\"(.?|.+?)\""# + internal let attributesChannelNumberRegex: RegularExpression = #"tvg-chno=\"(.?|.+?)\""# + internal let attributesShiftRegex: RegularExpression = #"tvg-shift=\"(.?|.+?)\""# + internal let attributesGroupTitleRegex: RegularExpression = #"group-title=\"(.?|.+?)\""# +} diff --git a/Sources/M3UKit/Parsers/RegularExpression.swift b/Sources/M3UKit/RegularExpression.swift similarity index 85% rename from Sources/M3UKit/Parsers/RegularExpression.swift rename to Sources/M3UKit/RegularExpression.swift index 85a9633..b9ee694 100644 --- a/Sources/M3UKit/Parsers/RegularExpression.swift +++ b/Sources/M3UKit/RegularExpression.swift @@ -23,19 +23,19 @@ import Foundation -struct RegularExpression { - let regex: NSRegularExpression +internal struct RegularExpression { + internal let regex: NSRegularExpression - init(_ regex: NSRegularExpression) { + internal init(_ regex: NSRegularExpression) { self.regex = regex } - func numberOfMatches(source: String) -> Int { + internal func numberOfMatches(source: String) -> Int { let sourceRange = NSRange(source.startIndex.. String? { + internal func firstMatch(in source: String) -> String? { let sourceRange = NSRange(source.startIndex.. [Range] { + internal func matchingRanges(in source: String) -> [Range] { let sourceRange = NSRange(source.startIndex..