Initial creation of the NPOKit framework

This commit is contained in:
Jeroen Wesbeek 2018-02-02 17:18:35 +01:00
commit cb6432c711
76 changed files with 2443 additions and 0 deletions

76
.gitignore vendored Normal file
View File

@ -0,0 +1,76 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## Build generated
build/
DerivedData/
## Various settings
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata/
## Other
*.moved-aside
*.xcuserstate
## Obj-C/Swift specific
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
# Swift Packagemanager
.DS_Store
/.build
/Packages
/*.xcodeproj
# some devnotes that don't need to go into the repo
devnotes.md

23
.swiftlint.yml Normal file
View File

@ -0,0 +1,23 @@
disabled_rules:
- line_length
- trailing_whitespace
- todo
- large_tuple
- notification_center_detachment
opt_in_rules:
- force_unwrapping
excluded:
- Carthage
- Pods
- NPOKit/Carthage
- NPOKit/Pods
variable_name:
min_length: 2
file_length:
warning: 600
error: 1200
type_body_length:
warning: 350
error: 500
excluded:
- Pods

17
.travis.yml Normal file
View File

@ -0,0 +1,17 @@
language: objective-c
os: osx
osx_image: xcode9
env:
global:
- NSUnbufferedIO=YES
install: ./bin/install_swiftlint.sh
script:
- set -o pipefail
- swift --version
- swift package generate-xcodeproj
- xcodebuild -version
- xcodebuild -showsdks
- xcodebuild -list
- xcpretty -v
- xcodebuild -scheme "NPOKit-Package" -sdk "appletvsimulator11.0" -configuration Release clean build ONLY_ACTIVE_ARCH=NO CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO RUN_CLANG_STATIC_ANALYZER=YES|xcpretty -c
- swift build --verbose

202
LICENSE Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Jeroen Wesbeek
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

3
NOTICE Normal file
View File

@ -0,0 +1,3 @@
ASSETS
------
The included broadcaster and channel logos are copyright by the Nederlandse Publieke Omroep (NPO) and their respective copyright owners. The included imagery is included for demonstration purposes and can be considered as art.

21
NPOKit.podspec Normal file
View File

@ -0,0 +1,21 @@
Pod::Spec.new do |spec|
spec.name = 'NPOKit'
spec.version = '0.0.1'
spec.summary = 'NPOKit framework for interfacing with the Dutch Public Broadcaster'
spec.homepage = 'https://github.com/4np/NPOKit'
spec.license = { type: 'APACHE', file: 'LICENSE' }
spec.authors = { "Jeroen Wesbeek" => 'github@osx.eu' }
spec.documentation_url = 'https://github.com/4np/NPOKit/blob/master/README.md'
spec.platforms = { :ios => '11.0', :osx => '12.0', :tvos => '11.0' }
spec.requires_arc = true
spec.pod_target_xcconfig = { 'SWIFT_VERSION' => '4.0' }
spec.source = { :git => 'https://github.com/4np/NPOKit.git', :tag => "#{spec.version}" }
spec.default_subspecs = 'Core'
# main NPOKit Framework
spec.subspec 'Core' do |core|
core.source_files = 'Sources/NPOKit/**/*.{swift}'
end
end

28
Package.swift Normal file
View File

@ -0,0 +1,28 @@
// swift-tools-version:4.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "NPOKit",
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "NPOKit",
targets: ["NPOKit"])
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "NPOKit",
dependencies: []),
.testTarget(
name: "NPOKitTests",
dependencies: ["NPOKit"])
]
)

221
README.md Normal file
View File

@ -0,0 +1,221 @@
# NPOKit
[![Build Status](https://travis-ci.org/4np/NPOKit.svg?branch=master)](https://travis-ci.org/4np/NPOKit)
[![Release](https://img.shields.io/github/release/4np/NPOKit.svg)](https://github.com/4np/UitzendingGemist/releases/latest)
[![Platform](https://img.shields.io/badge/platform-tvOS%2011-green.svg?maxAge=3600)](https://developer.apple.com/tvos/)
[![Swift](https://img.shields.io/badge/language-Swift-ed523f.svg?maxAge=3600)](https://swift.org)
[![Open Issues](https://img.shields.io/github/issues/4np/NPOKit.svg?maxAge=3600)](https://github.com/4np/NPOKit/issues)
[![Closed Issues](https://img.shields.io/github/issues-closed/4np/NPOKit.svg?maxAge=3600)](https://github.com/4np/NPOKit/issues?q=is%3Aissue+is%3Aclosed)
`NPOKit` is a `Swift 4` framework for interfacing with Dutch Public Broadcaster's (_Nederlandse Publieke Omroep_ - [NPO](https://www.npo.nl)) APIs. It supports fetching _programs_, _episodes_ and _video steams_ for playback.
_Note: This project is in active development so method signatures might change between versions without notice! Consider this in alpha stage... Changes that are likely coming are removing the failure closure in favor of an error argument. Note that this has currently only been tested on tvOS!_
## Installation
Add something similar to the following lines to your `Podfile`. You may need to adjust based on your platform, version/branch etc.
### Cocoapods
Using cocoapods is the most common way of installing frameworks.
```
source 'https://github.com/CocoaPods/Specs.git'
platform :tvos, '11.0'
use_frameworks!
pod 'NPOKit', :git => 'https://github.com/4np/NPOKit.git'
```
### Swift Package Manager
Add the following entry to your package's dependencies:
```
.package(url: "https://github.com/4np/NPOKit.git", from: "0.0.1")
```
## Basic Usage
### Fetching programs
Below you'll find some sample code on how to implement some paginated fetching of programs. Unfortunately the API currently does not support sorting in alphabetical order, so the result will be based by the sort order the NPO returns (which is by most used).
```
func getProgramPaginator(successHandler: @escaping Paginator<Program>.SuccessHandler,
failureHandler: Paginator<Program>.FailureHandler? = nil) -> Paginator<Program>
```
#### Example:
This code assumes you use a _scroll view_ in your user interface, so something like a _table view_ or a _collection view_.
```swift
import NPOKit
class MyViewController: UIViewController {
private var paginator: Paginator<Item>?
private var programs = [Item]()
override func viewDidLoad() {
super.viewDidLoad()
// set up paginator
setupPaginator()
}
// MARK: Networking
private func setupPaginator() {
// set up paginator
paginator = NPOKit.shared.getProgramPaginator(successHandler: { [weak self] (paginator, programs) in
// on the main queue as this might involve UI updating
DispatchQueue.main.async {
// append the new batch of programs
self?.programs.append(contentsOf: programs)
// TODO: make sure you update the UI accordingly...
}
}, failureHandler: { (_) in
print("failure :'(")
})
// fetch the first page
paginator?.next()
}
}
// MARK: UIScrollViewDelegate
extension ProgramsViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let collectionView = collectionView, let paginator = paginator else { return }
let numberOfPagesToInitiallyFetch = 2
let yOffsetToLoadNextPage = collectionView.contentSize.height - (collectionView.bounds.height * CGFloat(numberOfPagesToInitiallyFetch))
guard scrollView.contentOffset.y > yOffsetToLoadNextPage else { return }
// fetch the next page of programs...
paginator.next()
}
}
```
### Fetching episodes
Fetching episodes works very much like fetching programs (see above), it just requires a `program` argument when setting up the paginator:
```
func getEpisodePaginator(for item: Item,
successHandler: @escaping Paginator<Episode>.SuccessHandler,
failureHandler: Paginator<Episode>.FailureHandler? = nil) -> Paginator<Episode>
```
### Fetching images
`Item` bases resources (like `Program` and `Episode`) may provide images for different usages. The most common way you would use those images on `tvOS` are for populating collection view cells, or by showing a header:
```
func fetchCollectionImage(for item: Item, completionHandler completed: @escaping (UIImage?, URLSessionDataTask?, Error?) -> Void) -> URLSessionDataTask?
func fetchHeaderImage(for item: Item, completionHandler: @escaping (UIImage?, URLSessionDataTask?, Error?) -> Void) -> URLSessionDataTask?
```
## Logging
`NPOKit` support logging through any logging framework your application uses by providing a `logging wrapper`. Below is an example of how to bind based on Dave Wood's [XCGLogger](https://github.com/DaveWoodCom/XCGLogger) that was developed completely in Swift.
### LoggerWrapper
First, you need to set up the `LoggerWrapper` which inherits from `NPOKitLogger`. It basically normalizes the logging calls to your logging framework of choice, in this case `XCGLogger`:
```
import Foundation
import NPOKit
// Logging Wrapper
class LoggerWrapper: NPOKitLogger {
public static let shared = LoggerWrapper()
override func verbose(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
log.verbose(closure, functionName: functionName, fileName: fileName, lineNumber: lineNumber, userInfo: userInfo)
}
override func debug(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
log.debug(closure, functionName: functionName, fileName: fileName, lineNumber: lineNumber, userInfo: userInfo)
}
override func info(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
log.info(closure, functionName: functionName, fileName: fileName, lineNumber: lineNumber, userInfo: userInfo)
}
override func warning(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
log.warning(closure, functionName: functionName, fileName: fileName, lineNumber: lineNumber, userInfo: userInfo)
}
override func error(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
log.error(closure, functionName: functionName, fileName: fileName, lineNumber: lineNumber, userInfo: userInfo)
}
}
```
### AppDelegate
In your `AppDelegate`'s `application:didFinishLaunchingWithOptions:` you need to bind your `LoggerWrapper` to `NPOKit`, and logging will work. Set the loglevel to `debug` to get debug information or `verbose` to more elaborate information like `GET` and `POST` requests.
```
import XCGLogger
import NPOKit
let log = XCGLogger.default
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// configure logging
log.setup(level: .verbose, showThreadName: false, showLevel: true, showFileNames: true, showLineNumbers: true)
// log entry
log.info("Application launched.")
log.logAppDetails()
// bind logger to NPOKit
NPOKit.shared.log = LoggerWrapper.shared
return true
}
...
}
```
# Working on `NPOKit`
In order to work on `NPOKit` in `Xcode`, you need to generate an Xcode project. Run the following command in the project root:
```
swift package generate-xcodeproj
```
_Note: the Xcode project will not be comitted to git._
# License
See the accompanying [LICENSE](LICENSE) and [NOTICE](NOTICE) files for more information.
```
Copyright 2018 Jeroen Wesbeek
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```

View File

@ -0,0 +1,32 @@
//
// CrossPlatform.swift
// NPOKitPackageDescription
//
// Created by Jeroen Wesbeek on 02/02/2018.
//
import Foundation
#if os(iOS) || os(tvOS)
import UIKit
public typealias UXImage = UIImage
extension UIImage {
convenience init?(withName name: String) {
self.init(named: name)
}
}
#endif
#if os(OSX)
import Cocoa
public typealias UXImage = NSImage
extension NSImage {
convenience init?(withName name: String) {
self.init(named: NSImage.Name(name))
}
}
#endif

View File

@ -0,0 +1,38 @@
//
// String+Obfuscation.swift
// NPO
//
// Created by Jeroen Wesbeek on 01/02/2018.
// Copyright © 2018 Jeroen Wesbeek. All rights reserved.
//
import Foundation
extension String {
/// Just come random key for XOR-ing so we don't have people Googling their
/// way here when searching for specific strings...
private static let xorKey = [UInt8]("5thjdfgd8fhfsd83hjeafsjds84hjs8w".utf8)
/// Obfuscate a String (String > XOR > Hex)
var obfuscated: String {
let text = [UInt8](self.utf8)
let encrypted = text.enumerated().map({ $0.element ^ String.xorKey[$0.offset] })
return encrypted.map { String(format: "%02x", $0) }.joined()
}
/// Deobfuscate a String (Hex > XOR > String)
var deobfuscated: String? {
var data = Data(capacity: self.count / 2)
guard let regex = try? NSRegularExpression(pattern: "[0-9a-f]{1,2}", options: .caseInsensitive) else { return nil }
regex.enumerateMatches(in: self, options: .anchored, range: NSRange(location: 0, length: utf16.count)) { (result, _, _) in
guard let match = result?.range, let range = Range(match, in: self), let number = UInt8(self[range], radix: 16) else { return }
var mutableNumber = number
data.append(&mutableNumber, count: 1)
}
let decrypted = data.enumerated().map({ $0.element ^ String.xorKey[$0.offset] })
return String(bytes: decrypted, encoding: .utf8)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "AVRO-TROS.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "KRO-NCRV.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "Omroep Max.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_101.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_BEST.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_CULTURA.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_NIEUWS.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_1.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_3.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "omroepBrabant.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "omroepGelderland.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_POLITIEK.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "rtvDrenthe.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_ZAPP.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "NPO_ZAPP_XTRA.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@ -0,0 +1,84 @@
//
// Channel.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
// Some unknown (incorrect?) channel names the API returns:
// - POMS_S_KRO_098910 -> Spoorloos
// - KN_1684845 -> Mijn Maria
// - OPVO -> The Passion
// - POMS_S_MAX_374456 -> Zwarte Zwanen
// - POW_03344601 -> Vier seizoenen aan de Amstel
// - VPWON_1246643 -> Ondersteboven Nederland in de jaren 60
// - POMS_S_NCRV_059866 -> Schepper & Co
// - RAD5 -> De dienst van Freek
public enum Channel: String, Codable {
case unkown
case npo1 = "NED1"
case npo2 = "NED2"
case npo3 = "NED3"
case zappelin = "ZAPP"
case zappelinExtra = "ZAPPE"
case cultura = "CULT"
case omroepGelderland = "GELD"
case rtvDrenthe = "TVDR"
case omroepBrabant = "BRAB"
}
extension Channel {
public var name: String? {
switch self {
case .unkown:
return nil
case .npo1:
return "NPO 1"
case .npo2:
return "NPO 2"
case .npo3:
return "NPO 3"
case .zappelin:
return "Zappelin"
case .zappelinExtra:
return "Zappelin Xtra"
case .cultura:
return "Cultura"
case .omroepGelderland:
return "Omroep Gelderland"
case .rtvDrenthe:
return "RTV Drenthe"
case .omroepBrabant:
return "Omroep Brabant"
}
}
public var logo: UXImage? {
switch self {
case .unkown:
return nil
case .npo1:
return UXImage(withName: "npo1")
case .npo2:
return UXImage(withName: "npo2")
case .npo3:
return UXImage(withName: "npo3")
case .zappelin:
return UXImage(withName: "zappelin24")
case .zappelinExtra:
return UXImage(withName: "zappxtra")
case .cultura:
return UXImage(withName: "cultura24")
case .omroepGelderland:
return UXImage(withName: "omroepGelderland")
case .rtvDrenthe:
return UXImage(withName: "rtvDrenthe")
case .omroepBrabant:
return UXImage(withName: "omroepBrabant")
}
}
}

View File

@ -0,0 +1,63 @@
//
// Component.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
/// There is also a component 'subType' but that always
/// seems to be 'default' (at least for now) and default
/// does not work well inside enums...
enum ComponentType: String, Codable {
case unknown
// Catalogue component types
case filter
case grid
case loadmore
// Home component types
case spotlightheader
case continuewatching
case subscription
case lane
}
struct Component: Codable {
var id: String
var title: String?
private var typeName: String
private var subTypeName: String
var isPlaceholder: Bool = false
var filters: [Filter]?
var data: ComponentData?
enum CodingKeys: String, CodingKey {
case id = "id"
case title
case typeName = "type"
case subTypeName = "subType"
case isPlaceholder
case filters
case data
}
}
// MARK: Calculated properties
extension Component {
// MARK: Enums
var type: ComponentType? {
return ComponentType(rawValue: typeName)
}
}
struct ComponentData: Codable {
var total: Int
var count: Int
var items: [Item]
}

View File

@ -0,0 +1,29 @@
//
// Filter.swift
// NPO
//
// Created by Jeroen Wesbeek on 14/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct Filter: Codable {
public private(set) var title: String
public private(set) var type: String
internal var argumentName: String
public private(set) var options = [FilterOption]()
enum CodingKeys: String, CodingKey {
case title
case type = "filterType"
case argumentName = "filterArgument"
case options
}
}
extension Filter: Equatable { }
public func == (lhs: Filter, rhs: Filter) -> Bool {
return lhs.type == rhs.type && lhs.argumentName == rhs.argumentName
}

View File

@ -0,0 +1,19 @@
//
// FilterLink.swift
// NPO
//
// Created by Jeroen Wesbeek on 18/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
struct FilterLink: Codable {
internal var href: URL
internal var collectionIdentifier: String?
enum CodingKeys: String, CodingKey {
case href
case collectionIdentifier = "collectionId"
}
}

View File

@ -0,0 +1,37 @@
//
// FilterOption.swift
// NPO
//
// Created by Jeroen Wesbeek on 18/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct FilterOption: Codable {
public internal(set) var title: String
internal var value: String?
public internal(set) var isDefault = false
private var links: [String: FilterLink]?
var isCollection: Bool {
return collectionIdentifier != nil
}
var collectionIdentifier: String? {
return links?["page"]?.collectionIdentifier
}
enum CodingKeys: String, CodingKey {
case title = "display"
case value
case isDefault = "default"
case links = "_links"
}
}
extension FilterOption: Equatable { }
public func == (lhs: FilterOption, rhs: FilterOption) -> Bool {
return lhs.value == rhs.value && lhs.title == rhs.title
}

View File

@ -0,0 +1,25 @@
//
// ProgramFilter.swift
// NPO
//
// Created by Jeroen Wesbeek on 18/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct ProgramFilter {
public var filter: Filter
public var option: FilterOption
public init(filter: Filter, option: FilterOption) {
self.filter = filter
self.option = option
}
}
extension ProgramFilter: Equatable { }
public func == (lhs: ProgramFilter, rhs: ProgramFilter) -> Bool {
return lhs.filter == rhs.filter && lhs.option == rhs.option
}

View File

@ -0,0 +1,14 @@
//
// GenreItem.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct GenreItem: Codable {
var id: String
public private(set) var terms: [String]
}

View File

@ -0,0 +1,165 @@
//
// NPOGenre.swift
// NPO
//
// Created by Jeroen Wesbeek on 30/06/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public enum NPOGenre: Int {
enum NPOSubGenre {
case movie(Int) // Film
case series(Int) // Series
case sport(Int) // Sport
case music(Int) // Muziek
case amusement(Int) // Amusement
case informative(Int) // Informatief / Sport informatie
case documentary(Int) // Documentaire
case thriller(Int) // Spanning
case animation(Int) // Animatie
case drama(Int) // Drama
case humor(Int) // Komisch
case soap(Int) // Soap serie
case detective(Int) // Detectives
case game(Int) // Spel / Quiz / (Sport) wedstrijd
case classical(Int) // Muziek - Klassiek
case popular(Int) // Muziek - Populair
case standUp(Int) // Cabaret
case news(Int) // Nieuws / actualiteiten
case health(Int) // Gezondheid / opvoeding
case food(Int) // Koken / eten
case art(Int) // Kunst / cultuur
case nature(Int) // Natuur
case religious(Int) // Religieus
case science(Int) // Wetenschap
case consumerInfo(Int) // Consumenten informatie
case travel(Int) // Reizen
case history(Int) // Geschiedenis
case home(Int) // Wonen / tuin
}
case youth = 1 // Jeugd
case film = 12 // Film
case series = 17 // Serie
case sport = 23 // Sport
case music = 26 // Muziek
case amusement = 29 // Amusement
case informative = 33 // Informatief
case documentary = 46 // Documentaire
func all() -> [NPOGenre] {
return [.youth, .film, .series, .sport, .music, .amusement, .informative, .documentary]
}
func subgenres() -> [NPOSubGenre] {
switch self {
case .youth:
return [.movie(12), .series(17), .sport(23), .music(26), .amusement(29), .informative(33), .documentary(46)]
case .film:
return [.thriller(14), .animation(15), .drama(16), .humor(13)]
case .series:
return [.thriller(19), .animation(20), .drama(21), .soap(22), .humor(18), .detective(55)]
case .sport:
return [.informative(24), .game(25)]
case .music:
return [.classical(27), .popular(28)]
case .amusement:
return [.game(30), .standUp(31), .humor(32)]
case .informative:
return [.game(34), .news(35), .health(36), .food(37), .art(38), .nature(39), .religious(40), .science(41),
.consumerInfo(42), .travel(43), .history(44), .home(45)]
case .documentary:
return [.health(47), .food(48), .art(49), .nature(50), .religious(51), .science(52), .travel(53), .history(54)]
}
}
}
//enum NPOSubgenre: Int {
// case humor
// case thriller
// case animation
// case drama
// case soap
// case detective = 38
//}
//
//enum NPOGenre: Int {
// // Jeugd
// case youth = 1
// case animation = 4
// case gameQuiz = 10
// case movie = 2
// case nature = 11
// case series = 3
// case sport = 5
// case music = 6
// case amusement = 7
// case informative = 8
// case documentary = 9
//
// // Film
// case film = 12
// case humor = 13
// case thriller = 14
// case animation = 15
// case drama = 16
//
// // Serie
// case series = 17
// case humor = 18
// case thriller = 19
// case animation = 20
// case drama = 21
// case soap = 22
// case detective = 55
//
// // Sport
// case sport = 23
// case information = 24
// case match = 25
//
// // Music
// case music = 26
// case classic = 27
// case popular = 28
//
// // Amusement
// case amusement = 29
// case game = 30 // Spel/Quiz
// case standup = 31 // Cabaret
// case humor = 32 // Komisch
//
// // Informatief
// case informative = 33
// case game = 34 // Spel/Quiz
// case news = 35 // Nieuws/Actualiteiten
// case health = 36 // Gezondheid/opvoeding
// case food = 37 // Koken/eten
// case art = 38 // Kunst/cultuur
// case nature = 39 // Natuur
// case religious = 40 // Religieus
// case science = 41 // Wetenschap
// case consumerInformation = 42 // Consumenten informatie
// case travel = 43 // Reizen
// case history = 44 // Geschiedenis
// case home = 45 // Wonen/Tuin
//
// // Documentaire
// case documentary = 46
// case health = 47 // Gezondheid/opvoeding
// case food = 48 // Koken/eten
// case art = 49 // Kunst/cultuur
// case nature = 50 // Natuur
// case religious = 51 // Religieus
// case science = 52 // Wetenschap
// case travel = 53 // Reizen
// case history = 54 // Geschiedenis
//}

View File

@ -0,0 +1,15 @@
//
// Image.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
struct Format: Codable {
var width: Int?
var height: Int?
var source: URL?
}

View File

@ -0,0 +1,29 @@
//
// FormatContainer.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
struct FormatContainer: Codable {
var original: Format?
var tv: Format?
var expanded: Format?
var maf: Format?
var phone: Format?
var tablet: Format?
var web: Format?
enum CodingKeys: String, CodingKey {
case original
case tv
case expanded = "tv-expanded"
case maf
case phone
case tablet
case web
}
}

View File

@ -0,0 +1,33 @@
//
// ImageContainer.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
struct ImageContainer: Codable {
var original: NPOImage?
var header: NPOImage?
var gridTile: NPOImage?
var laneTile: NPOImage?
var playerPoster: NPOImage?
var playerRecommendation: NPOImage?
var playerPostPlay: NPOImage?
var searchSuggestion: NPOImage?
var chromecastPostPlay: NPOImage?
enum CodingKeys: String, CodingKey {
case original
case header
case gridTile = "grid.tile"
case laneTile = "lane.tile"
case playerPoster = "player.poster"
case playerRecommendation = "player.recommedation"
case playerPostPlay = "player.post-play"
case searchSuggestion = "search.suggestion"
case chromecastPostPlay = "chromecast.post-play"
}
}

View File

@ -0,0 +1,29 @@
//
// NPOImage.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
struct NPOImage: Codable {
var title: String
var formats: FormatContainer
// We don't really care about these
var alt: String?
var source: String?
var created: Date?
var updated: Date?
enum CodingKeys: String, CodingKey {
case title
case formats
case alt
case source
case created = "createdAt"
case updated = "updatedAt"
}
}

View File

@ -0,0 +1,119 @@
//
// Item.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
/// Typealiases
public typealias Program = Item
public typealias Episode = Item
public enum ItemType: String, Codable {
case series
case broadcast
case fragment
}
public struct Item: Pageable {
var id: String
public private(set) var title: String
public private(set) var description: String?
private var typeName: String
private var channelName: String?
public private(set) var genres: [GenreItem]
public private(set) var broadcasters: [String]
var images: ImageContainer
public private(set) var isOnlyOnNPOPlus: Bool
// franchise
var franchiseTitle: String?
public private(set) var broadcastDate: Date?
var seasonNumber: Int?
var episodeNumber: Int?
public private(set) var episodeTitle: String?
var duration: TimeInterval?
// We don't really care about these:
// var sterTitle: String
// var links: ...
// var shareText: String?
// var shareURL: URL?
// var website: URL?
// var twitter: URL?
// var facebook: URL?
// var googlePlus: URL?
// var pinterest: URL?
// var instagram: URL?
// var snapchat: URL?
enum CodingKeys: String, CodingKey {
case id = "id"
case title
case description
case typeName = "type"
case channelName = "channel"
case genres
case broadcasters
case images
case isOnlyOnNPOPlus = "isOnlyOnNpoPlus"
// franchise
case franchiseTitle
case broadcastDate
case seasonNumber
case episodeNumber
case episodeTitle
case duration
// case sterTitle
// case links = "_links"
// case shareText
// case shareURL = "shareUrl"
// case website = "websiteUrl"
// case twitter = "twitterUrl"
// case facebook = "facebookUrl"
// case googlePlus = "googlePlusUrl"
// case pinterest = "pinterestUrl"
// case instagram = "instagramUrl"
// case snapchat = "snapchatUrl"
}
}
// MARK: Calculated properties
extension Item {
// MARK: Enums
var type: ItemType? {
return ItemType(rawValue: typeName)
}
public var channel: Channel? {
guard let name = channelName, let channel = Channel(rawValue: name) else { return nil }
return channel
}
// MARK: Image URLs
var collectionImageURL: URL? {
// try to return the source in order of preference
if let source = images.gridTile?.formats.tablet?.source { // generally 320x180
return source
}
return nil
}
var headerImageURL: URL? {
// try to return the source in order of preference
if let source = images.header?.formats.tv?.source {
return source
}
return nil
}
}

View File

@ -0,0 +1,21 @@
//
// LegacyPlaylist.swift
// NPO
//
// Created by Jeroen Wesbeek on 07/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct LegacyPlaylist: Codable {
public private(set) var errorCode = 0
public private(set) var wait = 0
public private(set) var url: URL
enum CodingKeys: String, CodingKey {
case errorCode = "errorcode"
case wait
case url
}
}

View File

@ -0,0 +1,29 @@
//
// LegacyStream.swift
// NPO
//
// Created by Jeroen Wesbeek on 07/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct LegacyStream: Codable {
public private(set) var limited = false
public private(set) var items = [[LegacyStreamItem]]()
enum CodingKeys: String, CodingKey {
case limited
case items
}
internal func hlsItem() -> LegacyStreamItem? {
for item in items {
if let hlsItem = item.first(where: { $0.format == "hls" }) {
return hlsItem
}
}
return nil
}
}

View File

@ -0,0 +1,33 @@
//
// LegacyStreamItem.swift
// NPO
//
// Created by Jeroen Wesbeek on 07/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct LegacyStreamItem: Codable {
internal private(set) var label: String
internal private(set) var contentType: String
private var rawURL: URL
internal private(set) var format: String
internal var url: URL? {
var components = URLComponents(url: rawURL, resolvingAgainstBaseURL: false)
// remove JSONP query items
let queryItems = components?.queryItems?.filter { !["type", "callback"].contains($0.name) }
components?.queryItems = queryItems
return components?.url
}
enum CodingKeys: String, CodingKey {
case label
case contentType
case rawURL = "url"
case format
}
}

View File

@ -0,0 +1,37 @@
//
// Token.swift
// NPO
//
// Created by Jeroen Wesbeek on 07/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
internal struct Token: Codable {
private static let lifetime = 3600 // token lifetime in seconds (1h)
internal let date = Date()
public private(set) var value: String
enum CodingKeys: String, CodingKey {
case value = "token"
}
internal var age: Double {
return Date().timeIntervalSince(date)
}
private var expiryDate: Date? {
return Date(timeInterval: Double(Token.lifetime), since: date)
}
internal var hasExpired: Bool {
guard let expiryDate = self.expiryDate else {
return true
}
let now = Date()
let comparison = expiryDate.compare(now)
return (comparison == .orderedAscending || comparison == .orderedSame)
}
}

View File

@ -0,0 +1,12 @@
//
// Pageable.swift
// NPO
//
// Created by Jeroen Wesbeek on 27/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public protocol Pageable: Codable {
}

View File

@ -0,0 +1,21 @@
//
// PagedItems.swift
// NPO
//
// Created by Jeroen Wesbeek on 29/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct PagedItems: Codable {
var title: String?
var components: [Component]?
//var links: Links
enum CodingKeys: String, CodingKey {
case title
case components
//case links = "_links"
}
}

View File

@ -0,0 +1,67 @@
//
// Paginator.swift
// NPO
//
// Created by Jeroen Wesbeek on 29/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public class Paginator<T> where T: Pageable {
public typealias FetchHandler = (_ paginator: Paginator<T>, _ page: Int, _ pageSize: Int) -> Void
public typealias SuccessHandler = (Paginator, [T]) -> Void
public typealias FailureHandler = (Paginator) -> Void
private enum PaginatorRequestStatus {
case none, inProgress, done
}
private var requestStatus: PaginatorRequestStatus = .none
private var fetchHandler: FetchHandler
private var successHandler: SuccessHandler
private var failureHandler: FailureHandler?
public private(set) var page: Int = 0
public private(set) var pageSize: Int = 20
public private(set) var numberOfPages = 100000
public private(set) var filters: [Filter]?
init<T>(ofType: T.Type,
pageSize: Int,
fetchHandler: @escaping FetchHandler,
successHandler: @escaping SuccessHandler,
failureHandler: FailureHandler? = nil) where T: Pageable {
self.pageSize = pageSize
self.fetchHandler = fetchHandler
self.successHandler = successHandler
self.failureHandler = failureHandler
}
public func success(results: [T], numberOfPages: Int?, filters: [Filter]?) {
requestStatus = .done
self.page += 1
if let numberOfPages = numberOfPages {
self.numberOfPages = numberOfPages
}
if let filters = filters {
self.filters = filters
}
successHandler(self, results)
}
public func failure() {
requestStatus = .done
failureHandler?(self)
}
public func next() {
guard page < numberOfPages && requestStatus != .inProgress else { return }
requestStatus = .inProgress
fetchHandler(self, page + 1, pageSize)
}
public func reset() {
page = 0
}
}

View File

@ -0,0 +1,37 @@
//
// Stream.swift
// NPO
//
// Created by Jeroen Wesbeek on 31/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public struct Stream: Codable {
var url: URL
var licenseToken: String
var licenseServer: URL
var certificateURL: URL?
var profile: String
var drm: String
var ip: String
var isLegacy: Bool
var isLive: Bool
var isCatchupAvailable: Bool
var heartbeatUrl: URL?
enum CodingKeys: String, CodingKey {
case url
case licenseToken
case licenseServer
case certificateURL = "certificateUrl"
case profile
case drm
case ip
case isLegacy = "legacy"
case isLive = "live"
case isCatchupAvailable = "catchupAvailable"
case heartbeatUrl
}
}

View File

@ -0,0 +1,76 @@
//
// NPOKit+Episodes.swift
// NPO
//
// Created by Jeroen Wesbeek on 31/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public extension NPOKit {
// MARK: Episodes
func getEpisodePaginator(for item: Item,
successHandler: @escaping Paginator<Episode>.SuccessHandler,
failureHandler: Paginator<Episode>.FailureHandler? = nil) -> Paginator<Episode> {
// define how to fetch paginated data
let fetchHandler: Paginator<Item>.FetchHandler = { [weak self] (paginator, page, pageSize) in
self?.getEpisodes(
forEndPoint: "media/series/\(item.id)/episodes?page=\(page)",
page: page,
pageSize: pageSize,
success: { (episodes, numberOfPages) in paginator.success(results: episodes, numberOfPages: numberOfPages, filters: nil) },
failure: { paginator.failure() })
}
// initialize the paginator
return Paginator(ofType: Episode.self, pageSize: 20, fetchHandler: fetchHandler, successHandler: successHandler, failureHandler: failureHandler)
}
// MARK: Generic fetcher
private func getEpisodes(forEndPoint endPoint: String,
page: Int,
pageSize: Int,
success: @escaping (_ programs: [Episode], _ numberOfPages: Int) -> Void,
failure: @escaping () -> Void) {
// define data task completion handler
let completionHandler: (Data?, URLResponse?, Error?) -> Void = { [weak self] (data, response, error) in
guard error == nil, let httpResponse = response as? HTTPURLResponse, (httpResponse.statusCode == 200), let responseData = data else {
self?.log?.error("Request failed... (\(String(describing: error?.localizedDescription))) \(String(describing: response))")
failure()
return
}
let decoder = JSONDecoder()
if #available(OSX 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
decoder.dateDecodingStrategy = .iso8601
}
guard let componentData = try? decoder.decode(ComponentData.self, from: responseData) else {
self?.log?.error("Could not decode JSON")
failure()
return
}
// determine the number of pages
let totalResults = componentData.total
let numberOfPages = Int(ceil(Double(totalResults) / Double(pageSize)))
// success handler
success(componentData.items, numberOfPages)
}
// create task
guard let task = dataTask(forEndpoint: endPoint, postData: nil, completionHandler: completionHandler) else {
log?.error("Could not create data task...")
failure()
return
}
// execute task
task.resume()
}
}

View File

@ -0,0 +1,90 @@
//
// NPOKit+Generics.swift
// NPO
//
// Created by Jeroen Wesbeek on 08/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public extension NPOKit {
// MARK: Fetch model
internal func fetchModel<T: Codable>(ofType type: T.Type, forEndpoint endPoint: String, postData: Data?, completionHandler: @escaping (T?, Error?) -> Void) {
//swiftlint:disable:next force_unwrapping
let url = URL(string: endPoint, relativeTo: apiURL)!
fetchModel(ofType: type, forURL: url, postData: postData, completionHandler: completionHandler)
}
internal func fetchModel<T: Codable>(ofType type: T.Type, forLegacyEndpoint endPoint: String, postData: Data?, completionHandler: @escaping (T?, Error?) -> Void) {
//swiftlint:disable:next force_unwrapping
let url = URL(string: endPoint, relativeTo: legacyAPIURL)!
fetchModel(ofType: type, forURL: url, postData: postData, completionHandler: completionHandler)
}
internal func fetchModel<T: Codable>(ofType type: T.Type, forURL url: URL, postData: Data?, completionHandler: @escaping (T?, Error?) -> Void) {
let task = dataTask(forUrl: url, postData: postData, cachePolicy: .reloadIgnoringLocalCacheData) { (data, response, error) in
let decoder = JSONDecoder()
if #available(OSX 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
decoder.dateDecodingStrategy = .iso8601
}
guard error == nil, let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let data = data, let element = try? decoder.decode(type, from: data) else {
completionHandler(nil, error)
return
}
completionHandler(element, nil)
}
task.resume()
}
// MARK: Data Task
internal func dataTask(forEndpoint endPoint: String, postData: Data?, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? {
//swiftlint:disable:next force_unwrapping
let url = URL(string: endPoint, relativeTo: apiURL)!
return dataTask(forUrl: url, postData: postData, cachePolicy: .returnCacheDataElseLoad, completionHandler: completionHandler)
}
internal func legacyDataTask(forEndpoint endPoint: String, postData: Data?, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? {
//swiftlint:disable:next force_unwrapping
let url = URL(string: endPoint, relativeTo: legacyAPIURL)!
return dataTask(forUrl: url, postData: postData, cachePolicy: .reloadIgnoringLocalCacheData, completionHandler: completionHandler)
}
internal func dataTask(forUrl url: URL, postData: Data?, cachePolicy: URLRequest.CachePolicy, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
// create request
var request = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: cacheInterval)
request.addValue(getUserAgent(), forHTTPHeaderField: "User-Agent")
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
// add token if this is the 2.0 API
if let apiString = apiURL?.absoluteString, url.absoluteString.starts(with: apiString), let key = apiKey.deobfuscated {
request.addValue(key, forHTTPHeaderField: "ApiKey")
request.addValue("tv", forHTTPHeaderField: "X-Npo-Platform")
}
// add post data if needed
if let data = postData {
request.httpMethod = "POST"
request.httpBody = data
log?.verbose("POST: \(request)")
} else {
log?.verbose("GET : \(request)")
}
// create task
return URLSession.shared.dataTask(with: request, completionHandler: completionHandler)
}
// MARK: User Agent
private func getUserAgent() -> String {
// Pretend we're a Samsung TV
return "Mozilla/5.0 (SMART-TV; Linux; Tizen 2.3) AppleWebkit/538.1 (KHTML, like Gecko) SamsungBrowser/1.0 TV Safari/538.1"
}
}

View File

@ -0,0 +1,57 @@
//
// NPOKit+Images.swift
// NPO
//
// Created by Jeroen Wesbeek on 28/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public extension NPOKit {
// MARK: Private methods
private func fetchImage(url: URL, completionHandler: @escaping (UXImage?, URLSessionDataTask?, Error?) -> Void) -> URLSessionDataTask? {
return fetchImage(url: url, cachePolicy: .returnCacheDataElseLoad, completionHandler: completionHandler)
}
private func fetchImage(url: URL, cachePolicy: URLRequest.CachePolicy, completionHandler: @escaping (UXImage?, URLSessionDataTask?, Error?) -> Void) -> URLSessionDataTask? {
guard let cacheInterval = TimeInterval(exactly: 600) else {
return nil
}
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: cacheInterval)
let session = URLSession.shared
var task: URLSessionDataTask!
task = session.dataTask(with: request) { (data, _, error) in
guard let data = data, let image = UXImage(data: data) else {
DispatchQueue.main.async {
completionHandler(nil, task, error)
}
return
}
DispatchQueue.main.async {
completionHandler(image, task, error)
}
}
// execute task
task.resume()
return task
}
// MARK: Public API
func fetchCollectionImage(for item: Item, completionHandler completed: @escaping (UXImage?, URLSessionDataTask?, Error?) -> Void) -> URLSessionDataTask? {
guard let url = item.collectionImageURL else { return nil }
return fetchImage(url: url, completionHandler: completed)
}
func fetchHeaderImage(for item: Item, completionHandler: @escaping (UXImage?, URLSessionDataTask?, Error?) -> Void) -> URLSessionDataTask? {
guard let url = item.headerImageURL else { return nil }
return fetchImage(url: url, completionHandler: completionHandler)
}
}

View File

@ -0,0 +1,46 @@
//
// NPOKit+LegacyStream.swift
// NPO
//
// Created by Jeroen Wesbeek on 07/12/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public extension NPOKit {
func legacyPlaylist(for item: Item, completionHandler: @escaping (LegacyPlaylist?, Error?) -> Void) {
legacyStream(for: item) { [weak self] (stream, error) in
guard let url = stream?.hlsItem()?.url else {
completionHandler(nil, error ?? NSError(domain: "eu.osx.tvos.NPO.error", code: -2, userInfo: nil))
return
}
// fetch playlist
self?.fetchModel(ofType: LegacyPlaylist.self, forURL: url, postData: nil, completionHandler: completionHandler)
}
}
func legacyStream(for item: Item, completionHandler: @escaping (LegacyStream?, Error?) -> Void) {
getToken { [weak self] (token, error) in
guard let token = token else {
completionHandler(nil, error ?? NSError(domain: "eu.osx.tvos.NPO.error", code: -1, userInfo: nil))
return
}
// fetch stream
self?.fetchModel(ofType: LegacyStream.self, forLegacyEndpoint: "/app.php/\(item.id)?adaptive=yes&token=\(token.value)", postData: nil, completionHandler: completionHandler)
}
}
private func getToken(completionHandler: @escaping (Token?, Error?) -> Void) {
// use cached token?
if let token = self.legacyToken, !token.hasExpired {
completionHandler(token, nil)
return
}
// fetch new token
fetchModel(ofType: Token.self, forLegacyEndpoint: "/app.php/auth", postData: nil, completionHandler: completionHandler)
}
}

View File

@ -0,0 +1,136 @@
//
// NPOKit+Programs.swift
// NPO
//
// Created by Jeroen Wesbeek on 28/06/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
internal enum Genre: String {
case none = ""
case documentary = "documentaire"
case movie = "film"
case youth = "jeugd"
case series = "serie"
case sport
case music = "muziek"
case amusement
case informative = "informatief"
static func all() -> [Genre] {
return [.documentary, .movie, .youth, .series, .sport, .music, .amusement, .informative]
}
}
public extension NPOKit {
// MARK: Program paginator
func getProgramPaginator(successHandler: @escaping Paginator<Program>.SuccessHandler,
failureHandler: Paginator<Program>.FailureHandler? = nil) -> Paginator<Program> {
return getProgramPaginator(using: nil, successHandler: successHandler, failureHandler: failureHandler)
}
func getProgramPaginator(using programFilters: [ProgramFilter]?,
successHandler: @escaping Paginator<Program>.SuccessHandler,
failureHandler: Paginator<Program>.FailureHandler? = nil) -> Paginator<Program> {
// define how to fetch paginated data
let fetchHandler: Paginator<Program>.FetchHandler = { [weak self] (paginator, page, pageSize) in
let endpoint = self?.getEndpoint(for: "page/catalogue", page: page, using: programFilters) ?? "page/catalogue?page=\(page)"
self?.getPrograms(
forEndpoint: endpoint,
page: page,
pageSize: pageSize,
success: { (programs, numberOfPages, filters) in paginator.success(results: programs, numberOfPages: numberOfPages, filters: filters) },
failure: { paginator.failure() })
}
// initialize the paginator
return Paginator(ofType: Program.self, pageSize: 20, fetchHandler: fetchHandler, successHandler: successHandler, failureHandler: failureHandler)
}
private func getEndpoint(for base: String, page: Int, using programFilters: [ProgramFilter]?) -> String {
var endpoint = "\(base)?page=\(page)"
guard let programFilters = programFilters else { return endpoint }
for programFilter in programFilters {
guard let value = programFilter.option.value else { continue }
endpoint += "&\(programFilter.filter.argumentName)=\(value)"
// when the current filter is a date filter and we're trying
// to filter for a particular day we should also send dateTo
if programFilter.filter.argumentName == "dateFrom" && !programFilter.option.title.contains("Afgelopen") {
endpoint += "&dateTo=\(value)"
}
}
return endpoint
}
private func getPrograms(forEndpoint endpoint: String,
page: Int,
pageSize: Int,
success: @escaping (_ programs: [Program], _ numberOfPages: Int, _ filters: [Filter]?) -> Void,
failure: @escaping () -> Void) {
// define data task completion handler
let completionHandler: (Data?, URLResponse?, Error?) -> Void = { [weak self] (data, response, error) in
guard error == nil, let httpResponse = response as? HTTPURLResponse, (httpResponse.statusCode == 200), let data = data else {
self?.log?.error("Request failed... (\(String(describing: error?.localizedDescription))) \(String(describing: response))")
failure()
return
}
let decoder = JSONDecoder()
if #available(OSX 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
decoder.dateDecodingStrategy = .iso8601
}
// guard let pagedItems = try? decoder.decode(PagedItems.self, from: data) else {
// DDLogError("Could not decode JSON")
// failure()
// return
// }
let pagedItems: PagedItems
do {
pagedItems = try decoder.decode(PagedItems.self, from: data)
} catch {
// see https://developer.apple.com/documentation/swift/decodingerror
self?.log?.error("Could not decode JSON (\(error)))")
failure()
return
}
// get the program data
guard let itemData = pagedItems.components?.filter({ $0.type == .grid }).first?.data else {
self?.log?.error("Could not get program data, end of pagination?")
failure()
return
}
// determine the number of pages
let totalResults = itemData.total
let numberOfPages = Int(ceil(Double(totalResults) / Double(pageSize)))
// get the filters
let filters = pagedItems.components?.filter({ $0.type == .filter }).first?.filters
// success handler
success(itemData.items, numberOfPages, filters)
}
// create task
guard let task = dataTask(forEndpoint: endpoint, postData: nil, completionHandler: completionHandler) else {
log?.error("Could not create data task...")
failure()
return
}
// execute task
task.resume()
}
}

View File

@ -0,0 +1,19 @@
//
// NPOKit+Stream.swift
// NPO
//
// Created by Jeroen Wesbeek on 31/10/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public extension NPOKit {
func stream(for item: Item, completionHandler: @escaping (Stream?, Error?) -> Void) {
// tv payload
let payload = "{\"profile\":\"hls\",\"viewer\":1061049068313,\"options\":{\"startOver\":false}}"
let jsonPayloadData = payload.data(using: .utf8)
fetchModel(ofType: Stream.self, forEndpoint: "media/\(item.id)/stream", postData: jsonPayloadData, completionHandler: completionHandler)
}
}

View File

@ -0,0 +1,28 @@
//
// NPOKit.swift
// NPO
//
// Created by Jeroen Wesbeek on 23/06/2017.
// Copyright © 2017 Jeroen Wesbeek. All rights reserved.
//
import Foundation
public class NPOKit {
open static let shared = NPOKit()
internal var apiKey = "50405d0c015250575e03090047565957510b575053435a53100e55095f160f12"
internal var apiURL = URL(string: "https://start-api.npo.nl")
internal var legacyAPIURL = URL(string: "https://ida.omroep.nl")
//swiftlint:disable:next force_unwrapping
internal let cacheInterval = TimeInterval(exactly: 600)!
public var log: NPOKitLogger?
// legacy token
internal var legacyToken: Token?
// MARK: Initialization
private init() {
log?.info("NPOKit initialized.")
}
}

View File

@ -0,0 +1,36 @@
//
// NPOKitLogger.swift
// NPOKit
//
// Created by Jeroen Wesbeek on 01/02/2018.
// Copyright © 2018 Jeroen Wesbeek. All rights reserved.
//
import Foundation
/// Logging Wrapper
open class NPOKitLogger {
public init() {
verbose("Initialized logger.")
}
open func verbose(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
// by default no logging
}
open func debug(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
// by default no logging
}
open func info(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
// by default no logging
}
open func warning(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
// by default no logging
}
open func error(_ closure: @autoclosure () -> Any?, functionName: StaticString = #function, fileName: StaticString = #file, lineNumber: Int = #line, userInfo: [String: Any] = [:]) {
// by default no logging
}
}

6
Tests/LinuxMain.swift Normal file
View File

@ -0,0 +1,6 @@
import XCTest
@testable import NPOKitTests
XCTMain([
testCase(NPOKitTests.allTests),
])

View File

@ -0,0 +1,16 @@
import XCTest
@testable import NPOKit
class NPOKitTests: XCTestCase {
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(NPOKit().text, "Hello, World!")
}
static var allTests = [
("testExample", testExample),
]
}