├── .gitignore
├── Tests
├── LinuxMain.swift
└── YoutubeDL_iOSTests
│ ├── XCTestManifests.swift
│ └── YoutubeDLTests.swift
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcschemes
│ ├── YoutubeDL_iOSTests.xcscheme
│ └── YoutubeDL-iOS.xcscheme
├── README.md
├── Package.swift
├── LICENSE
├── Package.resolved
└── Sources
└── YoutubeDL
├── HTTPRange.swift
├── Transcoder.swift
├── PythonDecoder.swift
├── Downloader.swift
└── YoutubeDL.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import YoutubeDLTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += YoutubeDLTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Tests/YoutubeDL_iOSTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(YoutubeDL_iOSTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # YoutubeDL-iOS
2 |
3 | This swift package enables you to use yt-dlp in your iOS apps.
4 |
5 | ## Warning
6 |
7 | This package is NOT AppStore-safe. Historically AppStore has been removing apps downloading videos from YouTube. Your app will likely be rejected if you include this package in your app.
8 |
9 | ## Installation
10 |
11 | ```
12 | .package(url: "https://github.com/kewlbear/YoutubeDL-iOS.git", from: "0.0.2")
13 | ```
14 |
15 | ## Usage
16 |
17 | See https://github.com/kewlbear/YoutubeDL.
18 |
19 | ## Credits
20 |
21 | This package uses yt-dlp Python module from https://github.com/yt-dlp/yt-dlp.
22 |
23 | ## License
24 |
25 | MIT
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "YoutubeDL-iOS",
7 | platforms: [.iOS(.v13),],
8 | products: [
9 | .library(
10 | name: "YoutubeDL",
11 | targets: ["YoutubeDL"]),
12 | ],
13 | dependencies: [
14 | .package(url: "https://github.com/kewlbear/FFmpeg-iOS-Lame", from: "0.0.6-b20230730-000000"),
15 | .package(url: "https://github.com/pvieito/PythonKit.git", from: "0.3.1"),
16 | .package(url: "https://github.com/kewlbear/Python-iOS.git", from: "0.1.1-b"),
17 | ],
18 | targets: [
19 | .target(
20 | name: "YoutubeDL",
21 | dependencies: ["Python-iOS", "PythonKit", "FFmpeg-iOS-Lame"]),
22 | .testTarget(
23 | name: "YoutubeDL_iOSTests",
24 | dependencies: ["YoutubeDL"]),
25 | ]
26 | )
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 Changbeom Ahn
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "FFmpeg-iOS-Lame",
6 | "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Lame",
7 | "state": {
8 | "branch": null,
9 | "revision": "c0b202a16a43933cb16bf9914c76e00f0d24d57e",
10 | "version": "0.0.6-b20230416-200000"
11 | }
12 | },
13 | {
14 | "package": "FFmpeg-iOS-Support",
15 | "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Support",
16 | "state": {
17 | "branch": null,
18 | "revision": "60df9313af3a5869255b9654cd88eede4646295b",
19 | "version": "0.0.1"
20 | }
21 | },
22 | {
23 | "package": "Python-iOS",
24 | "repositoryURL": "https://github.com/kewlbear/Python-iOS.git",
25 | "state": {
26 | "branch": null,
27 | "revision": "784b391aa25c765761998cacb2d1fd5c86014164",
28 | "version": "0.1.1-b20230312-012746"
29 | }
30 | },
31 | {
32 | "package": "PythonKit",
33 | "repositoryURL": "https://github.com/pvieito/PythonKit.git",
34 | "state": {
35 | "branch": null,
36 | "revision": "81f621d094a7c8923207efe5178f50dba1b56c39",
37 | "version": "0.3.1"
38 | }
39 | }
40 | ]
41 | },
42 | "version": 1
43 | }
44 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/YoutubeDL_iOSTests.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
14 |
15 |
17 |
23 |
24 |
25 |
26 |
27 |
37 |
38 |
44 |
45 |
47 |
48 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/Sources/YoutubeDL/HTTPRange.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPRange.swift
3 | //
4 | // Copyright (c) 2020 Changbeom Ahn
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 | //
24 |
25 | import Foundation
26 |
27 | extension HTTPURLResponse {
28 | public var contentRange: (String?, Range, Int64)? {
29 | var contentRange: String?
30 | if #available(iOS 13.0, *) {
31 | contentRange = value(forHTTPHeaderField: "Content-Range")
32 | } else {
33 | contentRange = allHeaderFields["Content-Range"] as? String
34 | }
35 | // print(#function, contentRange ?? "no Content-Range?")
36 |
37 | guard let string = contentRange else { return nil }
38 | let scanner = Scanner(string: string)
39 | var prefix: NSString?
40 | var start: Int64 = -1
41 | var end: Int64 = -1
42 | var size: Int64 = -1
43 | guard scanner.scanUpToCharacters(from: .decimalDigits, into: &prefix),
44 | scanner.scanInt64(&start),
45 | scanner.scanString("-", into: nil),
46 | scanner.scanInt64(&end),
47 | scanner.scanString("/", into: nil),
48 | scanner.scanInt64(&size) else { return nil }
49 | return (prefix as String?, Range(start...end), size)
50 | }
51 | }
52 |
53 | extension URLRequest {
54 | public mutating func setRange(start: Int64, fullSize: Int64) -> Int64 {
55 | let end = start + chunkSize - 1
56 | setValue("bytes=\(start)-\(end)", forHTTPHeaderField: "Range")
57 | return end
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/YoutubeDL-iOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
48 |
54 |
55 |
56 |
57 |
58 |
68 |
69 |
75 |
76 |
82 |
83 |
84 |
85 |
87 |
88 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/Sources/YoutubeDL/Transcoder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Transcoder.swift
3 | //
4 | // Copyright (c) 2020 Changbeom Ahn
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 | //
24 |
25 | import Foundation
26 | import FFmpegSupport
27 |
28 | public typealias TimeRange = Range
29 |
30 | public enum FFmpegError: Error {
31 | case exit(code: Int)
32 | }
33 |
34 | open class Transcoder {
35 | open var progressBlock: ((Double) -> Void)?
36 |
37 | public init(progressBlock: ((Double) -> Void)? = nil) {
38 | self.progressBlock = progressBlock
39 | }
40 |
41 | @available(iOS 13.0, *)
42 | open func transcode(from: URL, to url: URL, timeRange: TimeRange?, bitRate: Double?) throws {
43 | let pipe = Pipe()
44 | Task {
45 | if #available(iOS 15.0, *) {
46 | var info = [String: String]()
47 | let maxTime: Double
48 | if let timeRange = timeRange {
49 | maxTime = (timeRange.upperBound - timeRange.lowerBound) * 1_000_000
50 | } else {
51 | maxTime = 1_000_000 // FIXME: probe?
52 | }
53 | print(#function, "await lines", pipe.fileHandleForReading)
54 | for try await line in pipe.fileHandleForReading.bytes.lines {
55 | // print(#function, line)
56 | let components = line.split(separator: "=")
57 | assert(components.count == 2)
58 | let key = String(components[0])
59 | info[key] = String(components[1])
60 | if key == "progress" {
61 | // print(#function, info)
62 | if let time = Int(info["out_time_us"] ?? ""),
63 | time >= 0 { // FIXME: reset global variable(s) causing it
64 | let progress = Double(time) / maxTime
65 | print(#function, "progress:", progress
66 | // , info["out_time_us"] ?? "nil", time
67 | )
68 | progressBlock?(progress)
69 | }
70 | guard info["progress"] != "end" else { break }
71 | info.removeAll()
72 | }
73 | }
74 | print(#function, "no more lines?", pipe.fileHandleForReading)
75 | } else {
76 | // Fallback on earlier versions
77 | }
78 | }
79 |
80 | var args = [
81 | "FFmpeg-iOS",
82 | "-progress", "pipe:\(pipe.fileHandleForWriting.fileDescriptor)",
83 | "-nostats",
84 | ]
85 |
86 | if let timeRange = timeRange {
87 | args += [
88 | "-ss", "\(timeRange.lowerBound)",
89 | "-t", "\(timeRange.upperBound - timeRange.lowerBound)",
90 | ]
91 | }
92 |
93 | args += [
94 | "-i", from.path,
95 | ]
96 |
97 | if let bitRate = bitRate {
98 | args += [
99 | "-b:v", "\(Int(bitRate))k",
100 | ]
101 | }
102 |
103 | args += [
104 | "-c:v", "h264_videotoolbox",
105 | url.path,
106 | ]
107 |
108 | let code = ffmpeg(args)
109 | print(#function, code)
110 |
111 | try pipe.fileHandleForWriting.close()
112 |
113 | guard code == 0 else {
114 | throw FFmpegError.exit(code: code)
115 | }
116 | }
117 | }
118 |
119 | public func format(_ seconds: Int) -> String? {
120 | guard seconds >= 0 else {
121 | print(#function, "invalid seconds:", seconds)
122 | return nil
123 | }
124 |
125 | let (minutes, sec) = seconds.quotientAndRemainder(dividingBy: 60)
126 | var string = "\(sec)"
127 | guard minutes > 0 else {
128 | return string
129 | }
130 |
131 | let (hours, min) = minutes.quotientAndRemainder(dividingBy: 60)
132 | string = "\(min):" + (sec < 10 ? "0" : "") + string
133 | guard hours > 0 else {
134 | return string
135 | }
136 |
137 | return "\(hours):" + (min < 10 ? "0" : "") + string
138 | }
139 |
--------------------------------------------------------------------------------
/Tests/YoutubeDL_iOSTests/YoutubeDLTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2023 Changbeom Ahn
3 | //
4 | // Permission is hereby granted, free of charge, to any person obtaining a copy
5 | // of this software and associated documentation files (the "Software"), to deal
6 | // in the Software without restriction, including without limitation the rights
7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | // copies of the Software, and to permit persons to whom the Software is
9 | // furnished to do so, subject to the following conditions:
10 | //
11 | // The above copyright notice and this permission notice shall be included in
12 | // all copies or substantial portions of the Software.
13 | //
14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | // THE SOFTWARE.
21 | //
22 |
23 | import XCTest
24 | @testable import YoutubeDL
25 | import PythonKit
26 | import PythonSupport
27 |
28 | final class YoutubeDL_iOSTests: XCTestCase {
29 | func testPy_IsInitialized() {
30 | XCTAssertEqual(Py_IsInitialized(), 0)
31 | PythonSupport.initialize()
32 | XCTAssertEqual(Py_IsInitialized(), 1)
33 | }
34 |
35 | func testExtractInfo() async throws {
36 | let youtubeDL = YoutubeDL()
37 | let (formats, info) = try await youtubeDL.extractInfo(url: URL(string: "https://www.youtube.com/watch?v=WdFj7fUnmC0")!)
38 | print(formats, info)
39 | XCTAssertEqual(info.title, "YoutubeDL iOS app demo")
40 | XCTAssertGreaterThan(formats.count, 0)
41 | }
42 |
43 | func testDownload() async throws {
44 | let youtubeDL = YoutubeDL()
45 | youtubeDL.downloader = Downloader(backgroundURLSessionIdentifier: nil)
46 | isTest = true
47 | let url = try await youtubeDL.download(url: URL(string: "https://www.youtube.com/watch?v=WdFj7fUnmC0")!)
48 | print(#function, url)
49 | }
50 |
51 | func testDownloads() async throws {
52 | let youtubeDL = YoutubeDL()
53 | youtubeDL.downloader = Downloader(backgroundURLSessionIdentifier: nil)
54 | isTest = true
55 | var url = try await youtubeDL.download(url: URL(string: "https://www.youtube.com/watch?v=WdFj7fUnmC0")!)
56 | print(#function, url)
57 | url = try await youtubeDL.download(url: URL(string: "https://youtu.be/TaUuUDIg6no")!)
58 | print(#function, url)
59 | }
60 |
61 | func testError() async throws {
62 | let youtubeDL = YoutubeDL()
63 | do {
64 | _ = try await youtubeDL.extractInfo(url: URL(string: "https://apple.com")!)
65 | } catch {
66 | guard let pyError = error as? PythonError, case let .exception(exception, traceback: traceback) = pyError else {
67 | throw error
68 | }
69 | print(exception, traceback ?? "nil")
70 | let message = String(exception.args[0]) ?? ""
71 | XCTAssert(message.contains("Unsupported URL: "))
72 | }
73 | }
74 |
75 | func testPythonDecoder() async throws {
76 | let youtubeDL = YoutubeDL()
77 | let (formats, info) = try await youtubeDL.extractInfo(url: URL(string: "https://www.youtube.com/watch?v=WdFj7fUnmC0")!)
78 | print(formats, info)
79 | }
80 |
81 | func testDirect() async throws {
82 | print(FileManager.default.currentDirectoryPath)
83 | try await yt_dlp(argv: [
84 | // "-F",
85 | // "-f", "bestvideo+bestaudio[ext=m4a]/best",
86 | "https://m.youtube.com/watch?v=ezEYcU9Pp_w",
87 | "--no-check-certificates",
88 | ], progress: { dict in
89 | print(#function, dict["status"] ?? "no status?", dict["filename"] ?? "no filename?", dict["elapsed"] ?? "no elapsed", dict.keys)
90 | }, log: { level, message in
91 | print(#function, level, message)
92 | })
93 | }
94 |
95 | func testExtractMP3() async throws {
96 | print(FileManager.default.currentDirectoryPath)
97 | try await yt_dlp(argv: [
98 | "-x", "--audio-format", "mp3", "--embed-thumbnail", "--add-metadata",
99 | "https://youtu.be/Qc7_zRjH808",
100 | "--no-check-certificates",
101 | ])
102 | }
103 |
104 | @available(iOS 16.0, *)
105 | func testJson() async throws {
106 | print(FileManager.default.currentDirectoryPath)
107 | var filename: String?
108 | try await yt_dlp(argv: [
109 | "--write-info-json",
110 | "--skip-download",
111 | "https://youtube.com/shorts/y6bGD7WxHIU?feature=share",
112 | "--no-check-certificates",
113 | ], log: { level, message in
114 | print(#function, level, message)
115 | if let range = message.range(of: "Writing video metadata as JSON to: ") {
116 | filename = String(message[range.upperBound...])
117 | }
118 | })
119 |
120 | guard let filename else { fatalError() }
121 | let data = try Data(contentsOf: URL(filePath: filename))
122 | let info = try JSONDecoder().decode(Info.self, from: data)
123 | print(#function, info)
124 | }
125 |
126 | static var allTests = [
127 | ("testExtractInfo", testExtractInfo),
128 | ]
129 | }
130 |
--------------------------------------------------------------------------------
/Sources/YoutubeDL/PythonDecoder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PythonDecoder.swift
3 | //
4 | //
5 | // Created by 안창범 on 2021/11/17.
6 | //
7 |
8 | import Foundation
9 | import PythonKit
10 |
11 | open class PythonDecoder {
12 | public init() {}
13 |
14 | open func decode(_ type: T.Type, from pythonObject: PythonObject) throws -> T {
15 | try T(from: _PythonDecoder(pythonObject: pythonObject, codingPath: []))
16 | }
17 | }
18 |
19 | struct _PythonDecoder: Decoder {
20 | let pythonObject: PythonObject
21 |
22 | var codingPath: [CodingKey]
23 |
24 | var userInfo: [CodingUserInfoKey : Any] = [:]
25 |
26 | func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey {
27 | KeyedDecodingContainer(_KeyedDecodingContainer(dict: Dictionary(pythonObject)!, codingPath: codingPath))
28 | }
29 |
30 | func unkeyedContainer() throws -> UnkeyedDecodingContainer {
31 | _UnkeyedDecodingContainer(elements: Array(pythonObject), codingPath: codingPath)
32 | }
33 |
34 | func singleValueContainer() throws -> SingleValueDecodingContainer {
35 | _SingleValueDecodingContainer(pythonObject: pythonObject, codingPath: codingPath)
36 | }
37 | }
38 |
39 | struct _KeyedDecodingContainer: KeyedDecodingContainerProtocol {
40 | let dict: [String: PythonObject]
41 |
42 | var codingPath: [CodingKey]
43 |
44 | var allKeys: [Key] { dict.keys.compactMap(Key.init(stringValue:)) }
45 |
46 | func contains(_ key: Key) -> Bool {
47 | dict.keys.contains(key.stringValue)
48 | }
49 |
50 | func decodeNil(forKey key: Key) throws -> Bool {
51 | guard let object = dict[key.stringValue] else { return true }
52 | return object == Python.builtins["None"]
53 | }
54 |
55 | func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
56 | try value(key)
57 | }
58 |
59 | func decode(_ type: String.Type, forKey key: Key) throws -> String {
60 | try value(key)
61 | }
62 |
63 | func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
64 | try value(key)
65 | }
66 |
67 | func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
68 | fatalError()
69 | }
70 |
71 | func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
72 | try value(key)
73 | }
74 |
75 | func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
76 | fatalError()
77 | }
78 |
79 | func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
80 | fatalError()
81 | }
82 |
83 | func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
84 | fatalError()
85 | }
86 |
87 | func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
88 | fatalError()
89 | }
90 |
91 | func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
92 | fatalError()
93 | }
94 |
95 | func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
96 | fatalError()
97 | }
98 |
99 | func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
100 | fatalError()
101 | }
102 |
103 | func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
104 | fatalError()
105 | }
106 |
107 | func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
108 | fatalError()
109 | }
110 |
111 | func decode(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
112 | try T(from: _PythonDecoder(pythonObject: dict[key.stringValue]!, codingPath: codingPath + [key]))
113 | }
114 |
115 | func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer where NestedKey : CodingKey {
116 | fatalError()
117 | }
118 |
119 | func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer {
120 | fatalError()
121 | }
122 |
123 | func superDecoder() throws -> Decoder {
124 | fatalError()
125 | }
126 |
127 | func superDecoder(forKey key: Key) throws -> Decoder {
128 | fatalError()
129 | }
130 |
131 | func value(_ key: Key) throws -> T {
132 | guard let value = T(try value(for: key)) else {
133 | throw DecodingError.typeMismatch(
134 | T.self,
135 | DecodingError.Context(codingPath: codingPath,
136 | debugDescription: "type mismatch",
137 | underlyingError: nil))
138 | }
139 | return value
140 | }
141 |
142 | func value(for key: Key) throws -> PythonObject {
143 | guard let value = dict[key.stringValue] else {
144 | throw DecodingError.keyNotFound(
145 | key,
146 | DecodingError.Context(codingPath: codingPath,
147 | debugDescription: "invalid key",
148 | underlyingError: nil))
149 | }
150 | return value
151 | }
152 | }
153 |
154 | struct _UnkeyedDecodingContainer: UnkeyedDecodingContainer {
155 | struct CodingKeys: CodingKey {
156 | var stringValue: String
157 |
158 | var intValue: Int?
159 |
160 | init?(stringValue: String) {
161 | self.stringValue = stringValue
162 | }
163 |
164 | init?(intValue: Int) {
165 | self.intValue = intValue
166 | self.stringValue = ""
167 | }
168 | }
169 |
170 | let elements: [PythonObject]
171 |
172 | var codingPath: [CodingKey]
173 |
174 | var count: Int? { elements.count }
175 |
176 | var isAtEnd: Bool { !elements.indices.contains(currentIndex) }
177 |
178 | var currentIndex = 0
179 |
180 | var element: PythonObject {
181 | mutating get {
182 | defer { currentIndex += 1 }
183 | return elements[currentIndex]
184 | }
185 | }
186 |
187 | func decode(_ type: Int64.Type) throws -> Int64 {
188 | fatalError()
189 | }
190 |
191 | func decode(_ type: UInt64.Type) throws -> UInt64 {
192 | fatalError()
193 | }
194 |
195 | func decode(_ type: UInt32.Type) throws -> UInt32 {
196 | fatalError()
197 | }
198 |
199 | func decode(_ type: Double.Type) throws -> Double {
200 | fatalError()
201 | }
202 |
203 | func decode(_ type: String.Type) throws -> String {
204 | fatalError()
205 | }
206 |
207 | func decode(_ type: Int32.Type) throws -> Int32 {
208 | fatalError()
209 | }
210 |
211 | func decode(_ type: Int.Type) throws -> Int {
212 | fatalError()
213 | }
214 |
215 | func decode(_ type: UInt8.Type) throws -> UInt8 {
216 | fatalError()
217 | }
218 |
219 | func decode(_ type: UInt16.Type) throws -> UInt16 {
220 | fatalError()
221 | }
222 |
223 | func decode(_ type: Int8.Type) throws -> Int8 {
224 | fatalError()
225 | }
226 |
227 | func decode(_ type: UInt.Type) throws -> UInt {
228 | fatalError()
229 | }
230 |
231 | func decode(_ type: Int16.Type) throws -> Int16 {
232 | fatalError()
233 | }
234 |
235 | func decode(_ type: Bool.Type) throws -> Bool {
236 | fatalError()
237 | }
238 |
239 | func decode(_ type: Float.Type) throws -> Float {
240 | fatalError()
241 | }
242 |
243 | mutating func decode(_ type: T.Type) throws -> T where T : Decodable {
244 | let key = CodingKeys(intValue: currentIndex) // must be before calling element
245 | return try T(from: _PythonDecoder(pythonObject: element, codingPath: codingPath + [key!]))
246 | }
247 |
248 | func decodeIfPresent(_ type: String.Type) throws -> String? {
249 | fatalError()
250 | }
251 |
252 | func decodeIfPresent(_ type: Bool.Type) throws -> Bool? {
253 | fatalError()
254 | }
255 |
256 | func decodeIfPresent(_ type: Double.Type) throws -> Double? {
257 | fatalError()
258 | }
259 |
260 | func decodeIfPresent(_ type: Float.Type) throws -> Float? {
261 | fatalError()
262 | }
263 |
264 | func decodeIfPresent(_ type: Int.Type) throws -> Int? {
265 | fatalError()
266 | }
267 |
268 | func decodeIfPresent(_ type: UInt.Type) throws -> UInt? {
269 | fatalError()
270 | }
271 |
272 | func decodeIfPresent(_ type: Int8.Type) throws -> Int8? {
273 | fatalError()
274 | }
275 |
276 | func decodeIfPresent(_ type: Int16.Type) throws -> Int16? {
277 | fatalError()
278 | }
279 |
280 | func decodeIfPresent(_ type: Int32.Type) throws -> Int32? {
281 | fatalError()
282 | }
283 |
284 | func decodeIfPresent(_ type: Int64.Type) throws -> Int64? {
285 | fatalError()
286 | }
287 |
288 | func decodeIfPresent(_ type: UInt8.Type) throws -> UInt8? {
289 | fatalError()
290 | }
291 |
292 | func decodeIfPresent(_ type: UInt16.Type) throws -> UInt16? {
293 | fatalError()
294 | }
295 |
296 | func decodeIfPresent(_ type: UInt32.Type) throws -> UInt32? {
297 | fatalError()
298 | }
299 |
300 | func decodeIfPresent(_ type: UInt64.Type) throws -> UInt64? {
301 | fatalError()
302 | }
303 |
304 | func decodeIfPresent(_ type: T.Type) throws -> T? where T : Decodable {
305 | fatalError()
306 | }
307 |
308 | func decodeNil() throws -> Bool {
309 | fatalError()
310 | }
311 |
312 | func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer where NestedKey : CodingKey {
313 | fatalError()
314 | }
315 |
316 | func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer {
317 | fatalError()
318 | }
319 |
320 | func superDecoder() throws -> Decoder {
321 | fatalError()
322 | }
323 | }
324 |
325 | struct _SingleValueDecodingContainer: SingleValueDecodingContainer {
326 | let pythonObject: PythonObject
327 |
328 | var codingPath: [CodingKey]
329 |
330 | func decodeNil() -> Bool {
331 | fatalError()
332 | }
333 |
334 | func decode(_ type: Bool.Type) throws -> Bool {
335 | fatalError()
336 | }
337 |
338 | func decode(_ type: String.Type) throws -> String {
339 | String(pythonObject)!
340 | }
341 |
342 | func decode(_ type: Double.Type) throws -> Double {
343 | fatalError()
344 | }
345 |
346 | func decode(_ type: Float.Type) throws -> Float {
347 | fatalError()
348 | }
349 |
350 | func decode(_ type: Int.Type) throws -> Int {
351 | fatalError()
352 | }
353 |
354 | func decode(_ type: Int8.Type) throws -> Int8 {
355 | fatalError()
356 | }
357 |
358 | func decode(_ type: Int16.Type) throws -> Int16 {
359 | fatalError()
360 | }
361 |
362 | func decode(_ type: Int32.Type) throws -> Int32 {
363 | fatalError()
364 | }
365 |
366 | func decode(_ type: Int64.Type) throws -> Int64 {
367 | fatalError()
368 | }
369 |
370 | func decode(_ type: UInt.Type) throws -> UInt {
371 | fatalError()
372 | }
373 |
374 | func decode(_ type: UInt8.Type) throws -> UInt8 {
375 | fatalError()
376 | }
377 |
378 | func decode(_ type: UInt16.Type) throws -> UInt16 {
379 | fatalError()
380 | }
381 |
382 | func decode(_ type: UInt32.Type) throws -> UInt32 {
383 | fatalError()
384 | }
385 |
386 | func decode(_ type: UInt64.Type) throws -> UInt64 {
387 | fatalError()
388 | }
389 |
390 | func decode(_ type: T.Type) throws -> T where T : Decodable {
391 | fatalError()
392 | }
393 | }
394 |
--------------------------------------------------------------------------------
/Sources/YoutubeDL/Downloader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Downloader.swift
3 | //
4 | // Copyright (c) 2020 Changbeom Ahn
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 | //
24 |
25 | import UIKit
26 |
27 | public enum NotificationRequestIdentifier: String {
28 | case transcode
29 | }
30 |
31 | public enum Kind: String, CustomStringConvertible {
32 | case complete, videoOnly, audioOnly, otherVideo
33 |
34 | public var description: String { rawValue }
35 |
36 | static let separator = "-"
37 | }
38 |
39 | @available(iOS 12.0, *)
40 | open class Downloader: NSObject {
41 |
42 | typealias Continuation = CheckedContinuation
43 |
44 | public static let shared = Downloader(backgroundURLSessionIdentifier: "YoutubeDL-iOS")
45 |
46 | open lazy var session: URLSession = URLSession.shared
47 |
48 | var isDownloading = false
49 |
50 | let decimalFormatter = NumberFormatter()
51 |
52 | let percentFormatter = NumberFormatter()
53 |
54 | public let dateComponentsFormatter = DateComponentsFormatter()
55 |
56 | var t = ProcessInfo.processInfo.systemUptime
57 |
58 | var bytesWritten: Int64 = 0
59 |
60 | open var t0 = ProcessInfo.processInfo.systemUptime
61 |
62 | open var progress = Progress()
63 |
64 | var currentRequest: URLRequest?
65 |
66 | var didFinishBackgroundEvents: Continuation?
67 |
68 | lazy var stream: AsyncStream<(URL, Kind)> = {
69 | AsyncStream { continuation in
70 | streamContinuation = continuation
71 | }
72 | }()
73 |
74 | var streamContinuation: AsyncStream<(URL, Kind)>.Continuation?
75 |
76 | public lazy var directory: URL = {
77 | let url = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
78 | .appendingPathComponent("Downloads")
79 | try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
80 | return url
81 | }()
82 |
83 | init(backgroundURLSessionIdentifier: String?, createURLSession: Bool = true) {
84 | super.init()
85 |
86 | decimalFormatter.numberStyle = .decimal
87 |
88 | percentFormatter.numberStyle = .percent
89 | percentFormatter.minimumFractionDigits = 1
90 |
91 | guard createURLSession else { return }
92 |
93 | var configuration: URLSessionConfiguration
94 | if let identifier = backgroundURLSessionIdentifier {
95 | configuration = URLSessionConfiguration.background(withIdentifier: identifier)
96 | } else {
97 | configuration = .default
98 | }
99 |
100 | configuration.networkServiceType = .responsiveAV
101 |
102 | session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
103 | print(session, "created")
104 | }
105 |
106 | open func download(request: URLRequest, url: URL, resume: Bool) -> URLSessionDownloadTask {
107 | currentRequest = request
108 |
109 | let task = session.downloadTask(with: request)
110 | task.taskDescription = url.relativePath
111 | // task.priority = URLSessionTask.highPriority
112 |
113 | if resume {
114 | isDownloading = true
115 | task.resume()
116 | }
117 | return task
118 | }
119 | }
120 |
121 | public func removeItem(at url: URL) {
122 | do {
123 | try FileManager.default.removeItem(at: url)
124 | // print(#function, "removed", url.lastPathComponent)
125 | }
126 | catch {
127 | let error = error as NSError
128 | if error.domain != NSCocoaErrorDomain || error.code != CocoaError.fileNoSuchFile.rawValue {
129 | print(#function, error)
130 | }
131 | }
132 | }
133 |
134 | @available(iOS 12.0, *)
135 | extension Downloader: URLSessionDelegate {
136 | public convenience init(identifier: String) async {
137 | self.init(backgroundURLSessionIdentifier: identifier, createURLSession: false)
138 |
139 | await withCheckedContinuation { (continuation: Continuation) in
140 | didFinishBackgroundEvents = continuation
141 |
142 | let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
143 | session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
144 | }
145 | }
146 |
147 | public func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
148 | print(#function, session, error ?? "no error")
149 | }
150 |
151 | public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
152 | print(#function, session)
153 | didFinishBackgroundEvents?.resume()
154 | }
155 | }
156 |
157 | @available(iOS 12.0, *)
158 | extension Downloader: URLSessionTaskDelegate {
159 | public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
160 | // print(#function, session, task, error ?? "no error")
161 | if let error = error {
162 | print(#function, session, task, error)
163 | }
164 | }
165 | }
166 |
167 | public class StopWatch {
168 | let t0 = Date()
169 |
170 | let name: String
171 |
172 | public init(name: String = #function) {
173 | self.name = name
174 | report(item: #function)
175 | }
176 |
177 | deinit {
178 | // report(item: #function)
179 | }
180 |
181 | public func report(item: String? = nil) {
182 | let now = Date()
183 | print(now, item ?? name, "took", now.timeIntervalSince(t0), "seconds")
184 | }
185 | }
186 |
187 | @available(iOS 12.0, *)
188 | extension Downloader: URLSessionDownloadDelegate {
189 |
190 | public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
191 | guard let taskDescription = downloadTask.taskDescription else {
192 | print(#function, "no task description", downloadTask)
193 | return
194 | }
195 |
196 | // guard currentRequest == nil || downloadTask.originalRequest == currentRequest && downloadTask.originalRequest?.value(forHTTPHeaderField: "Range") == currentRequest?.value(forHTTPHeaderField: "Range") else {
197 | // print(#function, "ignore", downloadTask.info, "(current request:", currentRequest ?? "nil", ")")
198 | // return
199 | // }
200 |
201 | let (_, range, size) = (downloadTask.response as? HTTPURLResponse)?.contentRange
202 | ?? (nil, -1 ..< -1, -1)
203 | print(#function, range, size, downloadTask.info, currentRequest?.value(forHTTPHeaderField: "Range") ?? "no current request or range"
204 | // , session, location
205 | )
206 |
207 | let kind = downloadTask.kind
208 | let url = downloadTask.taskDescription.map {
209 | URL(fileURLWithPath: $0, relativeTo: directory)
210 | } ?? directory.appendingPathComponent("complete.mp4")
211 |
212 | do {
213 | func resume(selector: @escaping ([URLSessionDownloadTask]) -> URLSessionDownloadTask?) {
214 | Task {
215 | let tasks = await session.tasks.2
216 | guard let task = selector(tasks.filter { $0.state == .suspended }) else {
217 | print(#function, "no more task", tasks.map(\.state.rawValue))
218 | return
219 | }
220 | print(#function, task.kind, task.originalRequest?.value(forHTTPHeaderField: "Range") ?? "no range", task.taskDescription ?? "no task description")
221 | task.resume()
222 | }
223 | }
224 |
225 | if range.isEmpty {
226 | notify(body: "finished \(url.lastPathComponent)")
227 | removeItem(at: url)
228 | try FileManager.default.moveItem(at: location, to: url)
229 | print(#function, "moved to", url.path)
230 |
231 | resume { tasks in
232 | tasks.first { $0.hasPrefix(0) }
233 | ?? tasks.first
234 | }
235 |
236 | streamContinuation?.yield((url, kind))
237 | } else {
238 | // notify(body: "\(range.upperBound * 100 / size)% \(url.lastPathComponent)")
239 | let part = url.appendingPathExtension("part")
240 | let file = try FileHandle(forWritingTo: part)
241 |
242 | try file.seek(toOffset: UInt64(range.lowerBound))
243 |
244 | let data = try Data(contentsOf: location, options: .alwaysMapped)
245 |
246 | file.write(data)
247 |
248 | try file.close()
249 |
250 | guard range.upperBound >= size else {
251 | resume { tasks in
252 | tasks.first {
253 | $0.taskDescription == downloadTask.taskDescription
254 | && $0.hasPrefix(range.upperBound)
255 | }
256 | ?? tasks.first { $0.hasPrefix(0) }
257 | ?? tasks.first
258 | }
259 | return
260 | }
261 |
262 | resume { tasks in
263 | tasks.first { $0.hasPrefix(0) }
264 | ?? tasks.first
265 | }
266 |
267 | try FileManager.default.moveItem(at: part, to: url)
268 |
269 | let result = streamContinuation?.yield((url, kind))
270 | guard case .enqueued(remaining: _) = result else { fatalError() }
271 | }
272 |
273 | DispatchQueue.main.async {
274 | if self.progress.fileTotalCount != nil {
275 | self.progress.fileCompletedCount = (self.progress.fileCompletedCount ?? 0) + 1
276 | }
277 | }
278 | }
279 | catch {
280 | print(error)
281 | }
282 | }
283 |
284 | public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
285 | let t = ProcessInfo.processInfo.systemUptime
286 | guard t - self.t > 0.9 else {
287 | return
288 | }
289 |
290 | let elapsed = t - self.t
291 | self.t = t
292 | let (_, range, size) = (downloadTask.response as? HTTPURLResponse)?.contentRange ?? (nil, 0..<0, totalBytesExpectedToWrite)
293 | let count = range.lowerBound + totalBytesWritten - self.bytesWritten
294 | self.bytesWritten = range.lowerBound + totalBytesWritten
295 | let bytesPerSec = Double(count) / elapsed
296 | let remain = Double(size - self.bytesWritten) / bytesPerSec
297 |
298 | DispatchQueue.main.async {
299 | let progress = self.progress
300 | progress.totalUnitCount = size
301 | progress.completedUnitCount = self.bytesWritten
302 | progress.throughput = Int(bytesPerSec)
303 | progress.estimatedTimeRemaining = remain
304 | }
305 | }
306 | }
307 |
308 | extension URLSessionDownloadTask {
309 | public var kind: Kind {
310 | Kind(rawValue: URL(fileURLWithPath: taskDescription ?? "")
311 | .deletingPathExtension()
312 | .path.components(separatedBy: Kind.separator)
313 | .last ?? "")
314 | ?? .complete
315 | }
316 |
317 | func hasPrefix(_ start: Int64) -> Bool {
318 | (originalRequest?.value(forHTTPHeaderField: "Range") ?? "")
319 | .hasPrefix("bytes=\(start)-")
320 | }
321 | }
322 |
323 | var isTest = false
324 |
325 | // FIXME: move to view controller?
326 | @available(iOS 12.0, *)
327 | public func notify(body: String, identifier: String = "Download") {
328 | guard !isTest else { return }
329 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound, .providesAppNotificationSettings]) { (granted, error) in
330 | guard granted else {
331 | print(#function, "granted =", granted, error ?? "no error")
332 | return
333 | }
334 |
335 | print(#function, body)
336 | let content = UNMutableNotificationContent()
337 | content.body = body
338 | let notificationRequest = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
339 | UNUserNotificationCenter.current().add(notificationRequest, withCompletionHandler: nil)
340 | }
341 | }
342 |
--------------------------------------------------------------------------------
/Sources/YoutubeDL/YoutubeDL.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2020 Changbeom Ahn
3 | //
4 | // Permission is hereby granted, free of charge, to any person obtaining a copy
5 | // of this software and associated documentation files (the "Software"), to deal
6 | // in the Software without restriction, including without limitation the rights
7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | // copies of the Software, and to permit persons to whom the Software is
9 | // furnished to do so, subject to the following conditions:
10 | //
11 | // The above copyright notice and this permission notice shall be included in
12 | // all copies or substantial portions of the Software.
13 | //
14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | // THE SOFTWARE.
21 | //
22 |
23 | import Foundation
24 | import PythonKit
25 | import PythonSupport
26 | import AVFoundation
27 | import Photos
28 | import UIKit
29 | import FFmpegSupport
30 |
31 | // https://github.com/pvieito/PythonKit/pull/30#issuecomment-751132191
32 | let RTLD_DEFAULT = UnsafeMutableRawPointer(bitPattern: -2)
33 |
34 | func loadSymbol(_ name: String) -> T {
35 | unsafeBitCast(dlsym(RTLD_DEFAULT, name), to: T.self)
36 | }
37 |
38 | let Py_IsInitialized: @convention(c) () -> Int32 = loadSymbol("Py_IsInitialized")
39 |
40 | public struct Info: Codable {
41 | public var id: String
42 | public var title: String
43 | public var formats: [Format]
44 | public var description: String?
45 | public var upload_date: String?
46 | public var uploader: String?
47 | public var uploader_id: String?
48 | public var uploader_url: String?
49 | public var channel_id: String?
50 | public var channel_url: String?
51 | public var duration: TimeInterval?
52 | public var view_count: Int?
53 | public var average_rating: Double?
54 | public var age_limit: Int?
55 | public var webpage_url: String?
56 | public var categories: [String]?
57 | public var tags: [String]?
58 | public var playable_in_embed: Bool?
59 | public var is_live: Bool?
60 | public var was_live: Bool?
61 | public var live_status: String?
62 | public var release_timestamp: Int?
63 |
64 | public struct Chapter: Codable {
65 | public var title: String?
66 | public var start_time: TimeInterval?
67 | public var end_time: TimeInterval?
68 | }
69 |
70 | public var chapters: [Chapter]?
71 | public var like_count: Int?
72 | public var channel: String?
73 | public var availability: String?
74 | public var __post_extractor: String?
75 | public var original_url: String?
76 | public var webpage_url_basename: String
77 | public var extractor: String?
78 | public var extractor_key: String?
79 | public var playlist: [String]?
80 | public var playlist_index: Int?
81 | public var thumbnail: String?
82 | public var display_id: String?
83 | public var duration_string: String?
84 | public var requested_subtitles: [String]?
85 | public var __has_drm: Bool?
86 | }
87 |
88 | public extension Info {
89 | var safeTitle: String {
90 | String(title[..<(title.index(title.startIndex, offsetBy: 40, limitedBy: title.endIndex) ?? title.endIndex)])
91 | .replacingOccurrences(of: "/", with: "_")
92 | }
93 | }
94 |
95 | public struct Format: Codable {
96 | public var asr: Int?
97 | public var filesize: Int?
98 | public var format_id: String
99 | public var format_note: String?
100 | public var fps: Double?
101 | public var height: Int?
102 | public var quality: Double?
103 | public var tbr: Double?
104 | public var url: String
105 | public var width: Int?
106 | public var language: String?
107 | public var language_preference: Int?
108 | public var ext: String
109 | public var vcodec: String?
110 | public var acodec: String?
111 | public var dynamic_range: String?
112 | public var abr: Double?
113 | public var vbr: Double?
114 |
115 | public struct DownloaderOptions: Codable {
116 | public var http_chunk_size: Int
117 | }
118 |
119 | public var downloader_options: DownloaderOptions?
120 | public var container: String?
121 | public var `protocol`: String
122 | public var audio_ext: String
123 | public var video_ext: String
124 | public var format: String
125 | public var resolution: String?
126 | public var http_headers: [String: String]
127 | }
128 |
129 | let chunkSize: Int64 = 10_485_760 // https://github.com/yt-dlp/yt-dlp/blob/720c309932ea6724223d0a6b7781a0e92a74262c/yt_dlp/extractor/youtube.py#L2552
130 |
131 | public extension Format {
132 | var urlRequest: URLRequest? {
133 | guard let url = URL(string: url) else {
134 | return nil
135 | }
136 | var request = URLRequest(url: url)
137 | for (field, value) in http_headers {
138 | request.addValue(value, forHTTPHeaderField: field)
139 | }
140 |
141 | return request
142 | }
143 |
144 | var isAudioOnly: Bool { vcodec == "none" }
145 |
146 | var isVideoOnly: Bool { acodec == "none" }
147 | }
148 |
149 | public let defaultOptions: PythonObject = [
150 | "format": "bestvideo,bestaudio[ext=m4a]/best",
151 | "nocheckcertificate": true,
152 | "verbose": true,
153 | ]
154 |
155 | public enum YoutubeDLError: Error {
156 | case noPythonModule
157 | case canceled
158 | }
159 |
160 | open class YoutubeDL: NSObject {
161 | public struct Options: OptionSet, Codable {
162 | public let rawValue: Int
163 |
164 | public static let noRemux = Options(rawValue: 1 << 0)
165 | public static let noTranscode = Options(rawValue: 1 << 1)
166 | public static let chunked = Options(rawValue: 1 << 2)
167 | public static let background = Options(rawValue: 1 << 3)
168 |
169 | public static let all: Options = [.noRemux, .noTranscode, .chunked, .background]
170 |
171 | public init(rawValue: Int) {
172 | self.rawValue = rawValue
173 | }
174 | }
175 |
176 | struct Download: Codable {
177 | var formats: [Format]
178 | var directory: URL
179 | var safeTitle: String
180 | var options: Options
181 | var timeRange: TimeRange?
182 | var bitRate: Double?
183 | var transcodePending: Bool
184 | }
185 |
186 | public static let latestDownloadURL = URL(string: "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp")!
187 |
188 | public static var pythonModuleURL: URL = {
189 | guard let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
190 | .appendingPathComponent("io.github.kewlbear.youtubedl-ios") else { fatalError() }
191 | do {
192 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
193 | }
194 | catch {
195 | fatalError(error.localizedDescription)
196 | }
197 | return directory.appendingPathComponent("yt_dlp")
198 | }()
199 |
200 | open var transcoder: Transcoder?
201 |
202 | public var version: String?
203 |
204 | public var downloader = Downloader.shared
205 |
206 | // public var videoExists: Bool { FileManager.default.fileExists(atPath: Kind.videoOnly.url.path) }
207 |
208 | public lazy var downloadsDirectory: URL = downloader.directory {
209 | didSet { downloader.directory = downloadsDirectory }
210 | }
211 |
212 | internal var pythonObject: PythonObject?
213 |
214 | internal var options: PythonObject?
215 |
216 | lazy var finished: AsyncStream = {
217 | AsyncStream { continuation in
218 | finishedContinuation = continuation
219 | }
220 | }()
221 |
222 | var finishedContinuation: AsyncStream.Continuation?
223 |
224 | open var keepIntermediates = false
225 |
226 | lazy var postDownloadTask = Task {
227 | for await (url, kind) in downloader.stream {
228 | print(#function, kind, url.lastPathComponent)
229 |
230 | switch kind {
231 | case .complete:
232 | export(url)
233 | case .videoOnly, .audioOnly:
234 | let directory = url.deletingLastPathComponent()
235 | guard let download = pendingDownloads.first(where: { $0.directory.path == directory.path }) else {
236 | print(#function, "no download with", directory, pendingDownloads.map(\.directory))
237 | return
238 | }
239 | guard tryMerge(directory: directory, title: url.title, timeRange: download.timeRange) else { return }
240 | finishedContinuation?.yield(url)
241 | case .otherVideo:
242 | do {
243 | try await transcode(directory: url.deletingLastPathComponent())
244 | finishedContinuation?.yield(url)
245 | } catch {
246 | print(error)
247 | }
248 | }
249 | }
250 | }
251 |
252 | lazy var pendingDownloads: [Download] = {
253 | loadPendingDownloads()
254 | }() {
255 | didSet { savePendingDownloads() }
256 | }
257 |
258 | var pendingDownloadsURL: URL { downloadsDirectory.appendingPathComponent("PendingDownloads.json") }
259 |
260 | public var pendingTranscode: URL? {
261 | pendingDownloads.first { $0.transcodePending }?.directory
262 | }
263 |
264 | public override init() {
265 | super.init()
266 |
267 | _ = postDownloadTask
268 | }
269 |
270 | func loadPythonModule(downloadPythonModule: Bool = true) async throws -> PythonObject {
271 | if Py_IsInitialized() == 0 {
272 | PythonSupport.initialize()
273 | }
274 |
275 | if !FileManager.default.fileExists(atPath: Self.pythonModuleURL.path) {
276 | guard downloadPythonModule else {
277 | throw YoutubeDLError.noPythonModule
278 | }
279 | try await Self.downloadPythonModule()
280 | }
281 |
282 | let sys = try Python.attemptImport("sys")
283 | if !(Array(sys.path) ?? []).contains(Self.pythonModuleURL.path) {
284 | injectFakePopen(handler: popenHandler)
285 |
286 | sys.path.insert(1, Self.pythonModuleURL.path)
287 | }
288 |
289 | let pythonModule = try Python.attemptImport("yt_dlp")
290 | version = String(pythonModule.version.__version__)
291 | return pythonModule
292 | }
293 |
294 | func injectFakePopen(handler: PythonFunction) {
295 | runSimpleString("""
296 | import errno
297 | import os
298 |
299 | class Pop:
300 | def __init__(self, *args, **kwargs):
301 | print('Popen.__init__:', self, args)#, kwargs)
302 | if args[0][0] in ['ffmpeg', 'ffprobe']:
303 | self.__args = args
304 | else:
305 | raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), args[0])
306 |
307 | def communicate(self, *args, **kwargs):
308 | print('Popen.communicate:', self, args, kwargs)
309 | return self.handler(self, self.__args)
310 |
311 | def kill(self):
312 | print('Popen.kill:', self)
313 |
314 | def wait(self, **kwargs):
315 | print('Popen.wait:', self, kwargs)
316 |
317 | def __enter__(self):
318 | return self
319 |
320 | def __exit__(self, type, value, traceback):
321 | pass
322 |
323 | import subprocess
324 | subprocess.Popen = Pop
325 | """)
326 |
327 | let subprocess = Python.import("subprocess")
328 | subprocess.Popen.handler = handler.pythonObject
329 | }
330 |
331 | lazy var popenHandler = PythonFunction { args in
332 | print(#function, args)
333 | let popen = args[0]
334 | var result = Array(repeating: nil, count: 2)
335 | if var args: [String] = Array(args[1][0]) {
336 | // save standard out/error
337 | let stdout = dup(STDOUT_FILENO)
338 | let stderr = dup(STDERR_FILENO)
339 |
340 | // redirect standard out/error
341 | let outPipe = Pipe()
342 | let errPipe = Pipe()
343 | dup2(outPipe.fileHandleForWriting.fileDescriptor, STDOUT_FILENO)
344 | dup2(errPipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO)
345 |
346 | let exitCode = self.handleFFmpeg(args: args)
347 |
348 | // restore standard out/error
349 | dup2(stdout, STDOUT_FILENO)
350 | dup2(stderr, STDERR_FILENO)
351 |
352 | popen.returncode = PythonObject(exitCode)
353 |
354 | func read(pipe: Pipe) -> String? {
355 | guard let string = String(data: pipe.fileHandleForReading.availableData, encoding: .utf8) else {
356 | print(#function, "not UTF-8?")
357 | return nil
358 | }
359 | print(#function, string)
360 | return string
361 | }
362 |
363 | result[0] = read(pipe: outPipe)
364 | result[1] = read(pipe: errPipe)
365 | return Python.tuple(result)
366 | }
367 | return Python.tuple(result)
368 | }
369 |
370 | func handleFFmpeg(args: [String]) -> Int {
371 | var args = args
372 |
373 | let pipe = Pipe()
374 | defer {
375 | do {
376 | // print(#function, "close")
377 | try pipe.fileHandleForWriting.close()
378 | } catch {
379 | print(#function, error)
380 | }
381 | }
382 |
383 | if args.contains("-i") {
384 | if let timeRange {
385 | args.insert(contentsOf: [
386 | "-ss", "\(timeRange.lowerBound)",
387 | "-t", "\(timeRange.upperBound - timeRange.lowerBound)",
388 | ], at: 1)
389 | }
390 |
391 | if let progressBlock = willTranscode?() {
392 | if #available(iOS 15.0, *) {
393 | let maxTime: Double
394 | if let duration {
395 | maxTime = duration * 1_000_000
396 | } else if let timeRange = timeRange {
397 | maxTime = (timeRange.upperBound - timeRange.lowerBound) * 1_000_000
398 | } else {
399 | maxTime = 1_000_000 // FIXME: probe?
400 | }
401 |
402 | var info = [String: String]()
403 | pipe.fileHandleForReading.readabilityHandler = { fileHandle in
404 | guard let string = String(data: fileHandle.availableData, encoding: .utf8) else { return }
405 | for components in string.split(separator: "\n").map({ $0.split(separator: "=") }).filter({ $0.count == 2}) {
406 | let key = String(components[0])
407 | info[key] = String(components[1])
408 | if key == "progress" {
409 | // print(#function, info)
410 | if let time = Int(info["out_time_us"] ?? ""),
411 | time >= 0 { // FIXME: reset global variable(s) causing it
412 | let progress = Double(time) / maxTime
413 | // print(#function, "progress:", progress
414 | // , info["out_time_us"] ?? "nil", time
415 | // )
416 | progressBlock(progress)
417 | }
418 | guard info["progress"] != "end" else {
419 | fileHandle.readabilityHandler = nil
420 | break
421 | }
422 | info.removeAll()
423 | }
424 | }
425 | // print(#function, string)
426 | }
427 | } else {
428 | // Fallback on earlier versions
429 | }
430 |
431 | args.insert(contentsOf: [
432 | "-progress", "pipe:\(pipe.fileHandleForWriting.fileDescriptor)",
433 | "-nostats",
434 | ], at: 1)
435 | }
436 | }
437 |
438 | // print(#function, args)
439 | return args[0] == "ffmpeg" ? ffmpeg(args) : ffprobe(args)
440 | }
441 |
442 | var willTranscode: (() -> ((Double) -> Void)?)?
443 |
444 | func makePythonObject(_ options: PythonObject? = nil, initializePython: Bool = true) async throws -> PythonObject {
445 | let pythonModule = try await loadPythonModule()
446 | let options = options ?? defaultOptions
447 | pythonObject = pythonModule.YoutubeDL(options)
448 | self.options = options
449 | return pythonObject!
450 | }
451 |
452 | public typealias FormatSelector = (Info) async -> ([Format], URL?, TimeRange?, Double?, String)
453 |
454 | open func download(url: URL, options: Options = [.background, .chunked], formatSelector: FormatSelector? = nil) async throws -> URL {
455 | downloader.progress = Progress()
456 |
457 | var (formats, info) = try await extractInfo(url: url)
458 |
459 | var directory: URL?
460 | var timeRange: Range?
461 | let bitRate: Double?
462 | let title: String
463 | if let formatSelector = formatSelector {
464 | (formats, directory, timeRange, bitRate, title) = await formatSelector(info)
465 | guard !formats.isEmpty else { throw YoutubeDLError.canceled }
466 | } else {
467 | bitRate = formats[0].vbr
468 | title = info.safeTitle
469 | }
470 |
471 | pendingDownloads.append(Download(formats: [],
472 | directory: directory ?? downloadsDirectory,
473 | safeTitle: title,
474 | options: options,
475 | timeRange: timeRange,
476 | bitRate: bitRate,
477 | transcodePending: false))
478 |
479 | await downloader.session.allTasks.forEach { $0.cancel() }
480 |
481 | for format in formats {
482 | try download(format: format, resume: !downloader.isDownloading || isTest, chunked: options.contains(.chunked), directory: directory ?? downloadsDirectory, title: info.safeTitle)
483 | }
484 |
485 | for await url in finished {
486 | // FIXME: validate url
487 | return url
488 | }
489 | fatalError()
490 | }
491 |
492 | func savePendingDownloads() {
493 | do {
494 | try JSONEncoder().encode(pendingDownloads).write(to: pendingDownloadsURL)
495 | } catch {
496 | print(#function, error)
497 | }
498 | }
499 |
500 | func loadPendingDownloads() -> [Download] {
501 | do {
502 | return try JSONDecoder().decode([Download].self,
503 | from: try Data(contentsOf: pendingDownloadsURL))
504 | } catch {
505 | print(#function, error)
506 | return []
507 | }
508 | }
509 |
510 | func processPendingDownload() {
511 | guard let index = pendingDownloads.firstIndex(where: { !$0.formats.isEmpty }) else {
512 | return
513 | }
514 |
515 | let format = pendingDownloads[index].formats.remove(at: 0)
516 |
517 | Task {
518 | let download = pendingDownloads[index]
519 | try self.download(format: format, resume: true, chunked: download.options.contains(.chunked), directory: download.directory, title: download.safeTitle)
520 | }
521 | }
522 |
523 | func makeURL(directory: URL? = nil, title: String, kind: Kind, ext: String) -> URL {
524 | (directory ?? downloadsDirectory).appendingPathComponent(
525 | title
526 | .appending(Kind.separator)
527 | .appending(kind.rawValue))
528 | .appendingPathExtension(ext)
529 | }
530 |
531 | open func download(format: Format, resume: Bool, chunked: Bool, directory: URL, title: String) throws {
532 | let kind: Kind = format.isVideoOnly
533 | ? (!format.isTranscodingNeeded ? .videoOnly : .otherVideo)
534 | : (format.isAudioOnly ? .audioOnly : .complete)
535 |
536 | func download(for request: URLRequest, resume: Bool) throws {
537 | let progress: Progress? = downloader.progress
538 | progress?.kind = .file
539 | progress?.fileOperationKind = .downloading
540 | let url = makeURL(directory: directory, title: title, kind: kind, ext: format.ext)
541 | do {
542 | try Data().write(to: url)
543 | }
544 | catch {
545 | print(#function, error)
546 | }
547 | progress?.fileURL = url
548 |
549 | removeItem(at: url)
550 |
551 | let task = downloader.download(request: request, url: url, resume: resume)
552 |
553 | if task.hasPrefix(0) {
554 | guard FileManager.default.createFile(atPath: url.appendingPathExtension("part").path, contents: nil) else { fatalError() }
555 | }
556 |
557 | print(#function, "start download:", task.info)
558 | }
559 |
560 | if chunked, let size = format.filesize {
561 | guard var request = format.urlRequest else { fatalError() }
562 | var start: Int64 = 0
563 | while start < size {
564 | // https://github.com/ytdl-org/youtube-dl/issues/15271#issuecomment-362834889
565 | let end = request.setRange(start: start, fullSize: Int64(size))
566 | // print(#function, "first chunked size:", end + 1)
567 |
568 | try download(for: request, resume: resume && start == 0)
569 | start = end + 1
570 | }
571 | } else {
572 | guard let request = format.urlRequest else { fatalError() }
573 |
574 | try download(for: request, resume: resume)
575 | }
576 | }
577 |
578 | open func extractInfo(url: URL) async throws -> ([Format], Info) {
579 | let pythonObject: PythonObject
580 | if let _pythonObject = self.pythonObject {
581 | pythonObject = _pythonObject
582 | } else {
583 | pythonObject = try await makePythonObject()
584 | }
585 |
586 | print(#function, url)
587 | let info = try pythonObject.extract_info.throwing.dynamicallyCall(withKeywordArguments: ["": url.absoluteString, "download": false, "process": true])
588 | print(info)
589 | // print(#function, "throttled:", pythonObject.throttled)
590 |
591 | let format_selector = pythonObject.build_format_selector(options!["format"])
592 | let formats_to_download = format_selector(info)
593 | var formats: [Format] = []
594 | let decoder = PythonDecoder()
595 | for format in formats_to_download {
596 | let format = try decoder.decode(Format.self, from: format)
597 | formats.append(format)
598 | }
599 |
600 | return (formats, try decoder.decode(Info.self, from: info))
601 | }
602 |
603 | func tryMerge(directory: URL, title: String, timeRange: TimeRange?) -> Bool {
604 | let t0 = ProcessInfo.processInfo.systemUptime
605 |
606 | let videoURL = makeURL(directory: directory, title: title, kind: .videoOnly, ext: "mp4")
607 | let audioURL: URL = makeURL(directory: directory, title: title, kind: .audioOnly, ext: "m4a")
608 | let videoAsset = AVAsset(url: videoURL)
609 | let audioAsset = AVAsset(url: audioURL)
610 |
611 | guard let videoAssetTrack = videoAsset.tracks(withMediaType: .video).first,
612 | let audioAssetTrack = audioAsset.tracks(withMediaType: .audio).first else {
613 | print(#function,
614 | videoAsset.tracks(withMediaType: .video),
615 | audioAsset.tracks(withMediaType: .audio))
616 | return false
617 | }
618 |
619 | let composition = AVMutableComposition()
620 | let videoCompositionTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
621 | let audioCompositionTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
622 |
623 | do {
624 | try videoCompositionTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoAssetTrack.timeRange.duration), of: videoAssetTrack, at: .zero)
625 | let range: CMTimeRange
626 | if let timeRange = timeRange {
627 | range = CMTimeRange(start: CMTime(seconds: timeRange.lowerBound, preferredTimescale: 1),
628 | end: CMTime(seconds: timeRange.upperBound, preferredTimescale: 1))
629 | } else {
630 | range = CMTimeRange(start: .zero, duration: audioAssetTrack.timeRange.duration)
631 | }
632 | try audioCompositionTrack?.insertTimeRange(range, of: audioAssetTrack, at: .zero)
633 | print(#function, videoAssetTrack.timeRange, range)
634 | }
635 | catch {
636 | print(#function, error)
637 | return false
638 | }
639 |
640 | guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetPassthrough) else {
641 | print(#function, "unable to init export session")
642 | return false
643 | }
644 | let outputURL = directory.appendingPathComponent(title).appendingPathExtension("mp4")
645 |
646 | removeItem(at: outputURL)
647 |
648 | session.outputURL = outputURL
649 | session.outputFileType = .mp4
650 | print(#function, "merging...")
651 |
652 | DispatchQueue.main.async {
653 | let progress = self.downloader.progress
654 | progress.kind = nil
655 | progress.localizedDescription = NSLocalizedString("Merging...", comment: "Progress description")
656 | progress.localizedAdditionalDescription = nil
657 | progress.totalUnitCount = 0
658 | progress.completedUnitCount = 0
659 | progress.estimatedTimeRemaining = nil
660 | }
661 |
662 | session.exportAsynchronously {
663 | print(#function, "finished merge", session.status.rawValue)
664 | print(#function, "took", self.downloader.dateComponentsFormatter.string(from: ProcessInfo.processInfo.systemUptime - t0) ?? "?")
665 | if session.status == .completed {
666 | if !self.keepIntermediates {
667 | removeItem(at: videoURL)
668 | removeItem(at: audioURL)
669 | }
670 |
671 | self.export(outputURL)
672 | } else {
673 | print(#function, session.error ?? "no error?")
674 | }
675 | }
676 | return true
677 | }
678 |
679 | open func transcode(directory: URL) async throws {
680 | guard let download = pendingDownloads.first(where: { $0.directory.path == directory.path }) else {
681 | print(#function, "no download with", directory, pendingDownloads.map(\.directory))
682 | return
683 | }
684 |
685 | DispatchQueue.main.async {
686 | guard UIApplication.shared.applicationState == .active else {
687 | guard let index = self.pendingDownloads.firstIndex(where: { $0.directory.path == directory.path }) else { fatalError() }
688 | self.pendingDownloads[index].transcodePending = true
689 |
690 | notify(body: NSLocalizedString("AskTranscode", comment: "Notification body"), identifier: NotificationRequestIdentifier.transcode.rawValue)
691 | return
692 | }
693 |
694 | // let alert = UIAlertController(title: nil, message: NSLocalizedString("DoNotSwitch", comment: "Alert message"), preferredStyle: .alert)
695 | // alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Action"), style: .default, handler: nil))
696 | // self.topViewController?.present(alert, animated: true, completion: nil)
697 | }
698 |
699 | let url = makeURL(directory: directory, title: download.safeTitle, kind: .otherVideo, ext: "webm") // FIXME: ext
700 | let outURL = makeURL(directory: directory, title: download.safeTitle, kind: .videoOnly, ext: "mp4")
701 |
702 | removeItem(at: outURL)
703 |
704 | DispatchQueue.main.async {
705 | let progress = self.downloader.progress
706 | progress.kind = nil
707 | progress.localizedDescription = NSLocalizedString("Transcoding...", comment: "Progress description")
708 | progress.totalUnitCount = 100
709 | }
710 |
711 | let t0 = ProcessInfo.processInfo.systemUptime
712 |
713 | if transcoder == nil {
714 | transcoder = Transcoder()
715 | }
716 |
717 | transcoder?.progressBlock = { progress in
718 | print(#function, "progress:", progress)
719 | let elapsed = ProcessInfo.processInfo.systemUptime - t0
720 | let speed = progress / elapsed
721 | let ETA = (1 - progress) / speed
722 |
723 | guard ETA.isFinite else { return }
724 |
725 | DispatchQueue.main.async {
726 | let _progress = self.downloader.progress
727 | _progress.completedUnitCount = Int64(progress * 100)
728 | _progress.estimatedTimeRemaining = ETA
729 | }
730 | }
731 |
732 | defer {
733 | transcoder = nil
734 | }
735 |
736 | try transcoder?.transcode(from: url, to: outURL, timeRange: download.timeRange, bitRate: download.bitRate)
737 |
738 | print(#function, "took", downloader.dateComponentsFormatter.string(from: ProcessInfo.processInfo.systemUptime - t0) ?? "?")
739 |
740 | if !keepIntermediates {
741 | removeItem(at: url)
742 | }
743 |
744 | notify(body: NSLocalizedString("FinishedTranscoding", comment: "Notification body"))
745 |
746 | tryMerge(directory: url.deletingLastPathComponent(), title: url.title, timeRange: download.timeRange)
747 | }
748 |
749 | internal func export(_ url: URL) {
750 | DispatchQueue.main.async {
751 | let progress = self.downloader.progress
752 | progress.localizedDescription = nil
753 | progress.localizedAdditionalDescription = nil
754 | progress.kind = .file
755 | progress.fileOperationKind = .copying
756 | progress.fileURL = url
757 | progress.completedUnitCount = 0
758 | progress.estimatedTimeRemaining = nil
759 | progress.throughput = nil
760 | progress.fileTotalCount = 1
761 | }
762 |
763 | PHPhotoLibrary.shared().performChanges({
764 | _ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
765 | // changeRequest.contentEditingOutput = output
766 | }) { (success, error) in
767 | print(#function, success, error ?? "")
768 |
769 | if let continuation = self.finishedContinuation {
770 | continuation.yield(url)
771 | } else {
772 | notify(body: NSLocalizedString("Download complete!", comment: "Notification body"))
773 | }
774 | DispatchQueue.main.async {
775 | let progress = self.downloader.progress
776 | progress.fileCompletedCount = 1
777 | do {
778 | let attributes = try FileManager.default.attributesOfItem(atPath: url.path) as NSDictionary
779 | progress.completedUnitCount = Int64(attributes.fileSize())
780 | }
781 | catch {
782 | progress.localizedDescription = error.localizedDescription
783 | }
784 | }
785 | }
786 | }
787 |
788 | fileprivate static func movePythonModule(_ location: URL) throws {
789 | removeItem(at: pythonModuleURL)
790 |
791 | try FileManager.default.moveItem(at: location, to: pythonModuleURL)
792 | }
793 |
794 | public static func downloadPythonModule(from url: URL = latestDownloadURL, completionHandler: @escaping (Swift.Error?) -> Void) {
795 | let task = URLSession.shared.downloadTask(with: url) { (location, response, error) in
796 | guard let location = location else {
797 | completionHandler(error)
798 | return
799 | }
800 | do {
801 | try movePythonModule(location)
802 |
803 | completionHandler(nil)
804 | }
805 | catch {
806 | print(#function, error)
807 | completionHandler(error)
808 | }
809 | }
810 |
811 | task.resume()
812 | }
813 |
814 | public static func downloadPythonModule(from url: URL = latestDownloadURL) async throws {
815 | let stopWatch = StopWatch(); defer { stopWatch.report() }
816 | if #available(iOS 15.0, *) {
817 | let (location, _) = try await URLSession.shared.download(from: url)
818 | try movePythonModule(location)
819 | } else {
820 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
821 | downloadPythonModule(from: url) { error in
822 | if let error = error {
823 | continuation.resume(throwing: error)
824 | } else {
825 | continuation.resume()
826 | }
827 | }
828 | }
829 | }
830 | }
831 | }
832 |
833 | let av1CodecPrefix = "av01."
834 |
835 | public extension Format {
836 | var isRemuxingNeeded: Bool { isVideoOnly || isAudioOnly }
837 |
838 | var isTranscodingNeeded: Bool {
839 | self.ext == "mp4"
840 | ? (self.vcodec ?? "").hasPrefix(av1CodecPrefix)
841 | : self.ext != "m4a"
842 | }
843 | }
844 |
845 | extension URL {
846 | var part: URL {
847 | appendingPathExtension("part")
848 | }
849 |
850 | var title: String {
851 | let name = deletingPathExtension().lastPathComponent
852 | guard let range = name.range(of: Kind.separator, options: [.backwards]) else { return name }
853 | return String(name[.. Void)? = nil, log: ((String, String) -> Void)? = nil, makeTranscodeProgressBlock: (() -> ((Double) -> Void)?)? = nil) async throws {
865 | let context = Context()
866 | let yt_dlp = try await YtDlp(context: context)
867 |
868 | let (ydl_opts, all_urls) = try yt_dlp.parseOptions(args: argv)
869 |
870 | // https://github.com/yt-dlp/yt-dlp#adding-logger-and-progress-hook
871 |
872 | if let log {
873 | ydl_opts["logger"] = makeLogger(name: "MyLogger", log)
874 | }
875 |
876 | if let progress {
877 | ydl_opts["progress_hooks"] = [makeProgressHook(progress)]
878 | }
879 |
880 | let myPP = yt_dlp.makePostProcessor(name: "MyPP") { pythonSelf, info in
881 | do {
882 | let formats = try info.checking["requested_formats"]
883 | .map { try PythonDecoder().decode([Format].self, from: $0) }
884 | guard let vbr = formats?.first(where: { $0.vbr != nil })?.vbr.map(Int.init) else {
885 | return ([], info)
886 | }
887 | pythonSelf._downloader.params["postprocessor_args"]
888 | .checking["merger+ffmpeg"]?
889 | .extend(["-b:v", "\(vbr)k"])
890 |
891 | duration = TimeInterval(info["duration"])
892 | // print(#function, "vbr:", vbr, "duration:", duration ?? "nil", args[0]._downloader.params)
893 | } catch {
894 | print(#function, error)
895 | }
896 | // print(#function, "MyPP.run:", info["requested_formats"])//, args)
897 | return ([], info)
898 | }
899 |
900 | // print(#function, ydl_opts)
901 | let ydl = yt_dlp.makeYoutubeDL(ydlOpts: ydl_opts)
902 |
903 | ydl.add_post_processor(myPP, when: "before_dl")
904 |
905 | context.willTranscode = makeTranscodeProgressBlock
906 |
907 | try ydl.download.throwing.dynamicallyCall(withArguments: all_urls)
908 | }
909 |
910 | /// Make custom logger. https://github.com/yt-dlp/yt-dlp#adding-logger-and-progress-hook
911 | /// - Parameters:
912 | /// - name: Python class name
913 | /// - log: closure to be called for each log messages
914 | /// - Returns: logger Python object
915 | public func makeLogger(name: String, _ log: @escaping (String, String) -> Void) -> PythonObject {
916 | PythonClass(name, members: [
917 | "debug": PythonInstanceMethod { params in
918 | let isDebug = String(params[1])!.hasPrefix("[debug] ")
919 | log(isDebug ? "debug" : "info", String(params[1]) ?? "")
920 | return Python.None
921 | },
922 | "info": PythonInstanceMethod { params in
923 | log("info", String(params[1]) ?? "")
924 | return Python.None
925 | },
926 | "warning": PythonInstanceMethod { params in
927 | log("warning", String(params[1]) ?? "")
928 | return Python.None
929 | },
930 | "error": PythonInstanceMethod { params in
931 | log("error", String(params[1]) ?? "")
932 |
933 | let traceback = Python.import("traceback")
934 | traceback.print_exc()
935 |
936 | return Python.None
937 | },
938 | ])
939 | .pythonObject()
940 | }
941 |
942 | public func makeProgressHook(_ progress: @escaping ([String: PythonObject]) -> Void) -> PythonObject {
943 | PythonFunction { (d: PythonObject) in
944 | let dict: [String: PythonObject] = Dictionary(d) ?? [:]
945 | progress(dict)
946 | return Python.None
947 | }
948 | .pythonObject
949 | }
950 |
951 | var timeRange: TimeRange?
952 |
953 | var duration: TimeInterval?
954 |
955 | typealias Context = YoutubeDL
956 |
957 | public class YtDlp {
958 | public class YoutubeDL {
959 | let ydl: PythonObject
960 |
961 | let urls: PythonObject
962 |
963 | init(ydl: PythonObject, urls: PythonObject) {
964 | self.ydl = ydl
965 | self.urls = urls
966 | }
967 | }
968 |
969 | let yt_dlp: PythonObject
970 |
971 | let context: Context
972 |
973 | public convenience init() async throws {
974 | try await self.init(context: Context())
975 | }
976 |
977 | init(context: Context) async throws {
978 | yt_dlp = try await context.loadPythonModule()
979 | self.context = context
980 | }
981 |
982 | public func parseOptions(args: [String]) throws -> (ydlOpts: PythonObject, allURLs: PythonObject) {
983 | let (parser, _, all_urls, ydl_opts) = try yt_dlp.parse_options.throwing.dynamicallyCall(withKeywordArguments: ["argv": args])
984 | .tuple4
985 |
986 | parser.destroy()
987 |
988 | return (ydl_opts, all_urls)
989 | }
990 |
991 | public func makePostProcessor(name: String, run: @escaping (PythonObject, PythonObject) -> ([String], PythonObject)) -> PythonObject {
992 | PythonClass(name, superclasses: [yt_dlp.postprocessor.PostProcessor], members: [
993 | "run": PythonFunction { args in
994 | let `self` = args[0]
995 | let info = args[1]
996 | let (filesToDelete, infoDict) = run(self, info)
997 | return Python.tuple([filesToDelete.pythonObject, infoDict])
998 | }
999 | ])
1000 | .pythonObject()
1001 | }
1002 |
1003 | func makeYoutubeDL(ydlOpts: PythonObject) -> PythonObject {
1004 | yt_dlp.YoutubeDL(ydlOpts)
1005 | }
1006 | }
1007 |
1008 | public extension YtDlp.YoutubeDL {
1009 | convenience init(args: [String]) async throws {
1010 | let yt_dlp = try await YtDlp()
1011 | let (ydlOpts, allUrls) = try yt_dlp.parseOptions(args: args)
1012 | self.init(ydl: yt_dlp.makeYoutubeDL(ydlOpts: ydlOpts), urls: allUrls)
1013 | }
1014 |
1015 | func download(urls: [URL]? = nil) throws {
1016 | let urls = urls?.map(\.absoluteString).pythonObject ?? self.urls
1017 | try ydl.download.throwing.dynamicallyCall(withArguments: urls)
1018 | }
1019 | }
1020 |
--------------------------------------------------------------------------------