├── .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 | --------------------------------------------------------------------------------