├── .github ├── actions │ └── build-archive-upload │ │ └── action.yml └── workflows │ └── main.yml ├── .gitignore ├── .swiftlint.yml ├── Cartfile ├── Cartfile.resolved ├── MediaKit ├── Info.plist ├── MediaKit.h ├── PreviewItem.swift ├── QuickLook │ └── QLViewHost.swift └── VLC │ └── VLCViewHost.swift ├── README.md ├── Screenshots └── 1.png ├── SwiftyTV ├── AppDelegate.swift └── Resources │ ├── Assets.xcassets │ ├── Brand Assets.brandassets │ │ ├── App Icon - App Store.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── app-icon-app-store.png │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ └── Front.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── app-icon-app-store.png │ │ │ │ └── Contents.json │ │ ├── App Icon.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Contents.json │ │ │ │ │ ├── primary-app-icon.png │ │ │ │ │ └── primary-app-icon@2x.png │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ └── Front.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── primary-app-icon.png │ │ │ │ └── primary-app-icon@2x.png │ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Top Shelf Image Wide.imageset │ │ │ ├── Contents.json │ │ │ ├── top-shelf-image-wide.png │ │ │ └── top-shelf-image-wide@2x.png │ │ └── Top Shelf Image.imageset │ │ │ ├── Contents.json │ │ │ ├── top-shelf-image.png │ │ │ └── top-shelf-image@2x.png │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ └── Info.plist ├── SwiftyTorrent.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ ├── MediaKit.xcscheme │ ├── SwiftyTV.xcscheme │ ├── SwiftyTorrent.xcscheme │ └── TorrentKit.xcscheme ├── SwiftyTorrent ├── Core │ ├── AppAssembly.swift │ ├── AppCoordinator.swift │ ├── AppDelegate.swift │ └── SceneDelegate.swift ├── Models │ ├── EZTVDataProvider.swift │ ├── File+PreviewItem.swift │ ├── File+UTI.swift │ ├── File.swift │ ├── IMDBDataProvider.swift │ └── Torrent+Files.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon.png │ │ │ ├── icon_60pt@2x.png │ │ │ ├── icon_60pt@3x.png │ │ │ ├── icon_76pt@2x.png │ │ │ └── icon_83.5@2x.png │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Icons │ │ └── uTorrent-320.png │ └── Info.plist ├── Stubs │ ├── StubEZTVDataProvider.swift │ ├── StubIMDBDataProvider.swift │ └── StubTorrentManager.swift └── UI │ ├── FileRow.swift │ ├── FileRowModel.swift │ ├── FilesView.swift │ ├── FilesViewModel.swift │ ├── MainView.swift │ ├── SearchRow.swift │ ├── SearchView.swift │ ├── SearchViewModel.swift │ ├── SettingsView.swift │ ├── SettingsViewModel.swift │ ├── TorrentRow.swift │ ├── TorrentRowModel.swift │ ├── TorrentsView.swift │ └── TorrentsViewModel.swift ├── TorrentKit ├── Core │ ├── STDownloadable.h │ ├── STFileEntry.h │ ├── STFileEntry.m │ ├── STMagnetURI.h │ ├── STMagnetURI.mm │ ├── STTorrent.h │ ├── STTorrent.m │ ├── STTorrentFile.h │ ├── STTorrentFile.mm │ ├── STTorrentManager.h │ ├── STTorrentManager.mm │ ├── STTorrentManagerProtocol.h │ ├── STTorrentState.h │ └── TorrentState.swift ├── Info.plist ├── Resources │ └── Torrents.plist ├── TorrentKit.h └── Utils │ ├── NSData+Hex.h │ └── NSData+Hex.m ├── bootstrap.sh └── carthage.sh /.github/actions/build-archive-upload/action.yml: -------------------------------------------------------------------------------- 1 | name: Build, archive and upload to TestFlight 2 | inputs: 3 | scheme: 4 | required: true 5 | type: string 6 | build_settings: 7 | required: true 8 | type: string 9 | platform: 10 | required: true 11 | type: string 12 | auth_key_issuer_id: 13 | required: true 14 | type: string 15 | auth_key_id: 16 | required: true 17 | type: string 18 | auth_key_path: 19 | required: true 20 | type: string 21 | 22 | runs: 23 | using: "composite" 24 | steps: 25 | - name: Build 26 | id: build 27 | env: 28 | scheme: ${{ inputs.scheme }} 29 | platform: ${{ inputs.platform }} 30 | auth_key_issuer_id: ${{ inputs.auth_key_issuer_id }} 31 | auth_key_id: ${{ inputs.auth_key_id }} 32 | auth_key_path: ${{ inputs.auth_key_path }} 33 | build_settings: ${{ inputs.build_settings }} 34 | shell: bash 35 | run: | 36 | archive_file=build/$scheme.xcarchive 37 | 38 | xcodebuild clean build archive \ 39 | -scheme "$scheme" \ 40 | -archivePath "$archive_file" \ 41 | -destination "generic/platform=$platform" \ 42 | -allowProvisioningUpdates \ 43 | -authenticationKeyPath "$auth_key_path" \ 44 | -authenticationKeyID $auth_key_id \ 45 | -authenticationKeyIssuerID $auth_key_issuer_id \ 46 | "$build_settings" #| xcpretty && exit ${PIPESTATUS[0]} 47 | 48 | - name: Create export options plist 49 | id: export-options-plist 50 | env: 51 | scheme: ${{ inputs.scheme }} 52 | shell: bash 53 | run: | 54 | export_plist=$RUNNER_TEMP/export.plist 55 | echo "::set-output name=EXPORT_OPTIONS_PLIST::$export_plist" 56 | 57 | cat > $export_plist << EOL 58 | 59 | 60 | 61 | 62 | method 63 | app-store 64 | 65 | 66 | EOL 67 | 68 | - name: Archive 69 | id: archive 70 | env: 71 | scheme: ${{ inputs.scheme }} 72 | auth_key_issuer_id: ${{ inputs.auth_key_issuer_id }} 73 | auth_key_id: ${{ inputs.auth_key_id }} 74 | auth_key_path: ${{ inputs.auth_key_path }} 75 | export_plist: ${{ steps.export-options-plist.outputs.EXPORT_OPTIONS_PLIST }} 76 | shell: bash 77 | run: | 78 | archive_file=build/$scheme.xcarchive 79 | export_dir=build/$scheme 80 | echo "::set-output name=IPA_FILE::$export_dir/$scheme.ipa" 81 | 82 | xcodebuild \ 83 | -exportArchive \ 84 | -archivePath "$archive_file" \ 85 | -exportPath "$export_dir" \ 86 | -exportOptionsPlist "$export_plist" \ 87 | -allowProvisioningUpdates \ 88 | -authenticationKeyPath "$auth_key_path" \ 89 | -authenticationKeyID $auth_key_id \ 90 | -authenticationKeyIssuerID $auth_key_issuer_id #| xcpretty && exit ${PIPESTATUS[0]} 91 | 92 | - name: Upload app to TestFlight 93 | id: upload 94 | env: 95 | ipa_file: ${{ steps.archive.outputs.IPA_FILE }} 96 | platform: ${{ inputs.platform }} 97 | auth_key_issuer_id: ${{ inputs.auth_key_issuer_id }} 98 | auth_key_id: ${{ inputs.auth_key_id }} 99 | shell: bash 100 | run: | 101 | xcrun altool \ 102 | --upload-app \ 103 | --file $ipa_file \ 104 | --type $platform \ 105 | --apiKey $auth_key_id \ 106 | --apiIssuer $auth_key_issuer_id 107 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Xcode - Build and Upload 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | name: Build using xcodebuild 11 | runs-on: macos-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Install the Apple certificate 18 | env: 19 | BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} 20 | P12_PASSWORD: ${{ secrets.P12_PASSWORD }} 21 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 22 | run: | 23 | # create variables 24 | CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 25 | KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db 26 | 27 | # import certificate from secrets 28 | echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output $CERTIFICATE_PATH 29 | 30 | # create temporary keychain 31 | security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 32 | security set-keychain-settings -lut 21600 $KEYCHAIN_PATH 33 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 34 | 35 | # import certificate to keychain 36 | security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH 37 | security list-keychain -d user -s $KEYCHAIN_PATH 38 | 39 | - name: Install App Store Connect private key 40 | id: appstore-private-key 41 | env: 42 | API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} 43 | API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} 44 | run: | 45 | PRIV_KEY_DIR="$HOME/private_keys" 46 | AUTH_KEY_FILE="$PRIV_KEY_DIR/AuthKey_$API_KEY_ID.p8" 47 | 48 | echo "::set-output name=AUTH_KEY_PATH::$AUTH_KEY_FILE" 49 | 50 | mkdir -p "$PRIV_KEY_DIR" 51 | echo "$API_PRIVATE_KEY" > "$AUTH_KEY_FILE" 52 | 53 | - name: Cache dependencies (Carthage) 54 | uses: actions/cache@v2 55 | id: cache-dependecies-carthage 56 | with: 57 | path: ./Carthage 58 | key: ${{ runner.OS }}-cache-carthage-${{ hashFiles('./Cartfile.resolved') }} 59 | restore-keys: | 60 | ${{ runner.OS }}-cache-carthage- 61 | 62 | - name: Bootstrap (Carthage) 63 | if: steps.cache-dependecies-carthage.outputs.cache-hit != 'true' 64 | run: ./carthage.sh bootstrap --platform ios,tvos --use-xcframeworks 65 | shell: bash 66 | 67 | - name: Cache dependencies 68 | uses: actions/cache@v2 69 | id: cache-dependecies 70 | with: 71 | path: ./Thirdparties 72 | key: ${{ runner.OS }}-cache-${{ hashFiles('./bootstrap.sh') }} 73 | restore-keys: | 74 | ${{ runner.OS }}-cache- 75 | 76 | - name: Bootstrap 77 | if: steps.cache-dependecies.outputs.cache-hit != 'true' 78 | run: ./bootstrap.sh 79 | shell: bash 80 | 81 | - name: Bump version 82 | env: 83 | build_number: ${{ github.run_number }} 84 | run: | 85 | xcrun agvtool new-version -all $((14+$build_number)) # temp fix 86 | 87 | - name: Build, acrhive and upload (SwiftyTorrent) 88 | uses: ./.github/actions/build-archive-upload 89 | with: 90 | scheme: SwiftyTorrent 91 | build_settings: 'ENABLE_BITCODE=NO' 92 | platform: ios 93 | auth_key_issuer_id: ${{ secrets.APPSTORE_ISSUER_ID }} 94 | auth_key_id: ${{ secrets.APPSTORE_API_KEY_ID }} 95 | auth_key_path: ${{ steps.appstore-private-key.outputs.AUTH_KEY_PATH }} 96 | 97 | - name: Build, acrhive and upload (SwiftyTV) 98 | uses: ./.github/actions/build-archive-upload 99 | with: 100 | scheme: SwiftyTV 101 | build_settings: 'ENABLE_BITCODE=NO' 102 | platform: tvos 103 | auth_key_issuer_id: ${{ secrets.APPSTORE_ISSUER_ID }} 104 | auth_key_id: ${{ secrets.APPSTORE_API_KEY_ID }} 105 | auth_key_path: ${{ steps.appstore-private-key.outputs.AUTH_KEY_PATH }} 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/swift,xcode,macos,objective-c 3 | # Edit at https://www.gitignore.io/?templates=swift,xcode,macos,objective-c 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### Objective-C ### 34 | # Xcode 35 | # 36 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 37 | 38 | ## Build generated 39 | build/ 40 | DerivedData/ 41 | 42 | ## Various settings 43 | *.pbxuser 44 | !default.pbxuser 45 | *.mode1v3 46 | !default.mode1v3 47 | *.mode2v3 48 | !default.mode2v3 49 | *.perspectivev3 50 | !default.perspectivev3 51 | xcuserdata/ 52 | 53 | ## Other 54 | *.moved-aside 55 | *.xccheckout 56 | *.xcscmblueprint 57 | 58 | ## Obj-C/Swift specific 59 | *.hmap 60 | *.ipa 61 | *.dSYM.zip 62 | *.dSYM 63 | 64 | # CocoaPods 65 | # We recommend against adding the Pods directory to your .gitignore. However 66 | # you should judge for yourself, the pros and cons are mentioned at: 67 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 68 | # Pods/ 69 | # Add this line if you want to avoid checking in source code from the Xcode workspace 70 | # *.xcworkspace 71 | 72 | # Carthage 73 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 74 | Carthage/Checkouts 75 | 76 | Carthage/Build 77 | 78 | # fastlane 79 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 80 | # screenshots whenever they are needed. 81 | # For more information about the recommended setup visit: 82 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 83 | 84 | fastlane/report.xml 85 | fastlane/Preview.html 86 | fastlane/screenshots/**/*.png 87 | fastlane/test_output 88 | 89 | # Code Injection 90 | # After new code Injection tools there's a generated folder /iOSInjectionProject 91 | # https://github.com/johnno1962/injectionforxcode 92 | 93 | iOSInjectionProject/ 94 | 95 | ### Objective-C Patch ### 96 | 97 | ### Swift ### 98 | # Xcode 99 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 100 | 101 | 102 | 103 | 104 | 105 | ## Playgrounds 106 | timeline.xctimeline 107 | playground.xcworkspace 108 | 109 | # Swift Package Manager 110 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 111 | # Packages/ 112 | # Package.pins 113 | # Package.resolved 114 | .build/ 115 | 116 | # CocoaPods 117 | # We recommend against adding the Pods directory to your .gitignore. However 118 | # you should judge for yourself, the pros and cons are mentioned at: 119 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 120 | # Pods/ 121 | # Add this line if you want to avoid checking in source code from the Xcode workspace 122 | # *.xcworkspace 123 | 124 | # Carthage 125 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 126 | # Carthage/Checkouts 127 | 128 | 129 | # fastlane 130 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 131 | # screenshots whenever they are needed. 132 | # For more information about the recommended setup visit: 133 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 134 | 135 | 136 | # Code Injection 137 | # After new code Injection tools there's a generated folder /iOSInjectionProject 138 | # https://github.com/johnno1962/injectionforxcode 139 | 140 | 141 | ### Xcode ### 142 | # Xcode 143 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 144 | 145 | ## User settings 146 | 147 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 148 | 149 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 150 | 151 | ### Xcode Patch ### 152 | *.xcodeproj/* 153 | !*.xcodeproj/project.pbxproj 154 | !*.xcodeproj/xcshareddata/ 155 | !*.xcworkspace/contents.xcworkspacedata 156 | /*.gcno 157 | **/xcshareddata/WorkspaceSettings.xcsettings 158 | 159 | # End of https://www.gitignore.io/api/swift,xcode,macos,objective-c 160 | 161 | Thirdparties/ 162 | sketch/ 163 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | - identifier_name 4 | - comment_spacing 5 | - multiple_closures_with_trailing_closure 6 | excluded: 7 | - Carthage 8 | - Thirdparties 9 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | binary "https://code.videolan.org/videolan/VLCKit/raw/master/Packaging/MobileVLCKit.json" ~> 3.3.0 2 | binary "https://code.videolan.org/videolan/VLCKit/raw/master/Packaging/TVVLCKit.json" ~> 3.3.0 3 | github "Swinject/Swinject" -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | binary "https://code.videolan.org/videolan/VLCKit/raw/master/Packaging/MobileVLCKit.json" "3.4.0" 2 | binary "https://code.videolan.org/videolan/VLCKit/raw/master/Packaging/TVVLCKit.json" "3.4.0" 3 | github "Swinject/Swinject" "2.8.1" 4 | -------------------------------------------------------------------------------- /MediaKit/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /MediaKit/MediaKit.h: -------------------------------------------------------------------------------- 1 | // 2 | // MediaKit.h 3 | // MediaKit 4 | // 5 | // Created by Danylo Kostyshyn on 02.07.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for MediaKit. 12 | FOUNDATION_EXPORT double MediaKitVersionNumber; 13 | 14 | //! Project version string for MediaKit. 15 | FOUNDATION_EXPORT const unsigned char MediaKitVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /MediaKit/PreviewItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewItem.swift 3 | // MediaKit 4 | // 5 | // Created by Danylo Kostyshyn on 02.07.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol PreviewItem { 12 | 13 | var previewItemURL: URL? { get } 14 | var previewItemTitle: String? { get } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /MediaKit/QuickLook/QLViewHost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QLViewHost.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 01.07.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | #if canImport(QuickLook) 11 | import QuickLook 12 | 13 | class QLPreviewItemWrapper: NSObject, QLPreviewItem { 14 | 15 | var previewItemURL: URL? { _previewItemURL } 16 | var previewItemTitle: String? { _previewItemTitle } 17 | 18 | private var _previewItemURL: URL? 19 | private var _previewItemTitle: String? 20 | 21 | init(previewItem: PreviewItem) { 22 | _previewItemURL = previewItem.previewItemURL 23 | _previewItemTitle = previewItem.previewItemTitle 24 | } 25 | 26 | } 27 | 28 | public struct QLViewHost: UIViewControllerRepresentable { 29 | 30 | public var previewItem: PreviewItem 31 | 32 | public init(previewItem: PreviewItem) { 33 | self.previewItem = previewItem 34 | } 35 | 36 | public func makeCoordinator() -> QLViewHost.Coordinator { 37 | return Coordinator(previewItem: previewItem) 38 | } 39 | 40 | public typealias Context = UIViewControllerRepresentableContext 41 | public typealias Controller = QLPreviewController 42 | 43 | public func makeUIViewController(context: Context) -> Controller { 44 | let controller = QLPreviewController() 45 | controller.dataSource = context.coordinator 46 | controller.delegate = context.coordinator 47 | return controller 48 | } 49 | 50 | public func updateUIViewController(_ uiViewController: Controller, context: Context) { 51 | uiViewController.dataSource = context.coordinator 52 | uiViewController.delegate = context.coordinator 53 | } 54 | 55 | public class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { 56 | 57 | var previewItem: PreviewItem 58 | 59 | init(previewItem: PreviewItem) { 60 | self.previewItem = previewItem 61 | super.init() 62 | } 63 | 64 | public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 65 | return 1 66 | } 67 | 68 | public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { 69 | return QLPreviewItemWrapper(previewItem: previewItem) 70 | } 71 | 72 | } 73 | } 74 | #endif 75 | -------------------------------------------------------------------------------- /MediaKit/VLC/VLCViewHost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VLCViewHost.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 01.07.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | #if os(iOS) 11 | import MobileVLCKit 12 | #elseif os(tvOS) 13 | import TVVLCKit 14 | #endif 15 | 16 | public struct VLCViewHost: UIViewControllerRepresentable { 17 | 18 | public var previewItem: PreviewItem 19 | 20 | public init(previewItem: PreviewItem) { 21 | self.previewItem = previewItem 22 | } 23 | 24 | public func makeCoordinator() -> VLCViewHost.Coordinator { 25 | return Coordinator(previewItem: previewItem) 26 | } 27 | 28 | public typealias Context = UIViewControllerRepresentableContext 29 | public typealias Controller = VLCPlayerViewController 30 | 31 | public func makeUIViewController(context: Context) -> Controller { 32 | let item = context.coordinator.previewItem 33 | return VLCPlayerViewController(previewItem: item) 34 | } 35 | 36 | public func updateUIViewController(_ uiViewController: Controller, context: Context) { } 37 | 38 | public static func dismantleUIViewController(_ uiViewController: Controller, coordinator: Coordinator) { } 39 | 40 | public class Coordinator: NSObject { 41 | 42 | let previewItem: PreviewItem 43 | 44 | init(previewItem: PreviewItem) { 45 | self.previewItem = previewItem 46 | super.init() 47 | } 48 | 49 | } 50 | 51 | } 52 | 53 | public final class VLCPlayerViewController: UIViewController { 54 | 55 | private var previewItem: PreviewItem 56 | private var player: VLCMediaPlayer 57 | private let controlsView = ControlsView() 58 | 59 | private var controlsHidden = true { 60 | didSet { 61 | // Bring `controlsView` in front of player's view when it becames visible 62 | view.bringSubviewToFront(controlsView) 63 | UIView.animate(withDuration: 0.3) { 64 | self.controlsView.alpha = self.controlsHidden ? 0.0 : 0.75 65 | } 66 | } 67 | } 68 | 69 | public init(previewItem: PreviewItem) { 70 | self.previewItem = previewItem 71 | self.player = VLCMediaPlayer() 72 | super.init(nibName: nil, bundle: nil) 73 | if let url = previewItem.previewItemURL { 74 | player.media = VLCMedia(url: url) 75 | } 76 | } 77 | 78 | required init?(coder: NSCoder) { 79 | fatalError("init(coder:) has not been implemented") 80 | } 81 | 82 | // MARK: - 83 | 84 | public override func viewDidLoad() { 85 | super.viewDidLoad() 86 | 87 | player.delegate = self 88 | player.drawable = view 89 | player.play() 90 | 91 | controlsView.alpha = 0.0 92 | controlsView.delegate = self 93 | 94 | view.addSubview(controlsView) 95 | controlsView.translatesAutoresizingMaskIntoConstraints = false 96 | var constraints = [NSLayoutConstraint]() 97 | constraints.append(contentsOf: [ 98 | controlsView.centerXAnchor.constraint( 99 | equalTo: view.centerXAnchor, 100 | constant: 0.0 101 | ), 102 | controlsView.bottomAnchor.constraint( 103 | equalTo: view.bottomAnchor, 104 | constant: -50.0 105 | ) 106 | ]) 107 | constraints.forEach({ $0.isActive = true }) 108 | 109 | view.addGestureRecognizer( 110 | UITapGestureRecognizer( 111 | target: self, 112 | action: #selector(viewDidTap(_:)) 113 | ) 114 | ) 115 | } 116 | 117 | @objc func viewDidTap(_ sender: Any) { 118 | controlsHidden.toggle() 119 | } 120 | 121 | private func togglePlayback() { 122 | if player.isPlaying { 123 | player.pause() 124 | } else { 125 | player.play() 126 | } 127 | } 128 | 129 | private var hideTimer: Timer? 130 | 131 | private func hidePlaybackControlsAfterDelay() { 132 | hideTimer?.invalidate() 133 | hideTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in 134 | if self.player.isPlaying { 135 | self.controlsHidden = true 136 | } 137 | } 138 | } 139 | 140 | public override func touchesMoved(_ touches: Set, with event: UIEvent?) { 141 | hideTimer?.invalidate() 142 | } 143 | 144 | public override func touchesEnded(_ touches: Set, with event: UIEvent?) { 145 | hidePlaybackControlsAfterDelay() 146 | } 147 | 148 | public override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { 149 | for press in presses { 150 | switch press.type { 151 | case .playPause: 152 | togglePlayback() 153 | case .select: 154 | controlsHidden.toggle() 155 | default: break 156 | } 157 | } 158 | } 159 | 160 | } 161 | 162 | extension VLCPlayerViewController: VLCMediaPlayerDelegate { 163 | 164 | public func mediaPlayerStateChanged(_ aNotification: Notification) { 165 | guard let player = aNotification.object as? VLCMediaPlayer else { return } 166 | switch player.state { 167 | case .stopped: break 168 | case .opening: break 169 | case .buffering: break 170 | case .ended: break 171 | case .error: break 172 | case .playing: break 173 | case .paused: break 174 | case .esAdded: break 175 | default: break 176 | } 177 | } 178 | 179 | } 180 | 181 | extension VLCPlayerViewController: ControlsViewDelegate { 182 | 183 | func controlView(_ controlsView: VLCPlayerViewController.ControlsView, 184 | didTapAction action: VLCPlayerViewController.ControlsView.Action) { 185 | switch action { 186 | case .dismiss: 187 | dismiss(animated: true, completion: nil) 188 | case .backward: 189 | player.jumpBackward(10) 190 | case .playPause: 191 | togglePlayback() 192 | case .forward: 193 | player.jumpForward(10) 194 | } 195 | } 196 | 197 | } 198 | 199 | protocol ControlsViewDelegate: AnyObject { 200 | func controlView(_ controlsView: VLCPlayerViewController.ControlsView, 201 | didTapAction action: VLCPlayerViewController.ControlsView.Action) 202 | } 203 | 204 | extension VLCPlayerViewController { 205 | 206 | class ControlsView: UIView { 207 | 208 | //swiftlint:disable:next nesting 209 | enum Action: CaseIterable { 210 | case dismiss 211 | case backward 212 | case playPause 213 | case forward 214 | 215 | var systemImageName: String { 216 | switch self { 217 | case .dismiss: return "arrow.down.right.and.arrow.up.left" 218 | case .backward: return "gobackward.10" 219 | case .playPause: return "playpause" 220 | case .forward: return "goforward.10" 221 | } 222 | } 223 | } 224 | 225 | weak var delegate: ControlsViewDelegate? 226 | 227 | private let stackView: UIStackView = { 228 | let view = UIStackView() 229 | view.axis = .horizontal 230 | view.distribution = .fillEqually 231 | return view 232 | }() 233 | 234 | init() { 235 | super.init(frame: .zero) 236 | setup() 237 | } 238 | 239 | required init?(coder: NSCoder) { 240 | fatalError("init(coder:) has not been implemented") 241 | } 242 | 243 | // MARK: - 244 | 245 | override var intrinsicContentSize: CGSize { 246 | CGSize(width: 200.0, height: 44.0) 247 | } 248 | 249 | private func setup() { 250 | backgroundColor = .white 251 | 252 | layer.cornerRadius = 10.0 253 | layer.borderWidth = 1.0 254 | layer.borderColor = UIColor.gray.cgColor 255 | 256 | addSubview(stackView) 257 | stackView.translatesAutoresizingMaskIntoConstraints = false 258 | var constraints = [NSLayoutConstraint]() 259 | constraints.append(contentsOf: [ 260 | stackView.leadingAnchor.constraint( 261 | equalTo: leadingAnchor, 262 | constant: 0.0 263 | ), 264 | stackView.topAnchor.constraint( 265 | equalTo: topAnchor, 266 | constant: 0.0 267 | ), 268 | stackView.trailingAnchor.constraint( 269 | equalTo: trailingAnchor, 270 | constant: 0.0 271 | ), 272 | stackView.bottomAnchor.constraint( 273 | equalTo: bottomAnchor, 274 | constant: 0.0 275 | ) 276 | ]) 277 | constraints.forEach({ $0.isActive = true }) 278 | 279 | // Add buttons 280 | for action in Action.allCases { 281 | let dismissButton = UIButton( 282 | type: .system, 283 | primaryAction: 284 | UIAction( 285 | title: "", 286 | image: UIImage(systemName: action.systemImageName), 287 | attributes: [], 288 | state: .on, 289 | handler: { _ in self.delegate?.controlView(self, didTapAction: action) } 290 | ) 291 | ) 292 | dismissButton.tintColor = .black 293 | stackView.addArrangedSubview(dismissButton) 294 | } 295 | } 296 | 297 | } 298 | 299 | } 300 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # SwiftyTorrent 3 | 4 | Basic torrent client for iOS based on `libtorrent`, built using `SwiftUI` and `Combine`. 5 | 6 | List of public domain torrents can be found [here](https://webtorrent.io/free-torrents) (by WebTorrent) 7 | 8 | ![1](Screenshots/1.png) 9 | 10 | ## Requirements 11 | 12 | - `Xcode 13.0` 13 | - `iOS 15.0` 14 | 15 | ## Build 16 | 17 | # Install Carthage dependencies 18 | $ ./carthage.sh bootstrap --platform ios,tvos --use-xcframeworks 19 | 20 | # Install libtorrent 21 | $ ./bootstrap.sh 22 | 23 | $ open SwiftyTorrent.xcodeproj 24 | 25 | Downloads can be found at: 26 | 27 | Files.app -> Locations -> On My iPhone -> SwiftyTorrent -> Downloads 28 | 29 | ## Features 30 | 31 | - opens *.torrent files and magnet links 32 | - integrates with Files app 33 | - restores session between launches 34 | 35 | ## TODO 36 | 37 | - file details screen 38 | - per file prioritization 39 | - ~~pieces prioritization logic for video streaming~~ 40 | - ~~option to remove downloaded files when removing torrent~~ 41 | - proper event/error handling 42 | - ~~quick look~~ 43 | - ~~integrate VLC player for video playback~~ 44 | - session status header 45 | - ~~app icon~~ 46 | 47 | ___ 48 | [@danylo_kos](https://twitter.com/danylo_kos) 49 | -------------------------------------------------------------------------------- /Screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/Screenshots/1.png -------------------------------------------------------------------------------- /SwiftyTV/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftyTV 4 | // 5 | // Created by Danylo Kostyshyn on 02.07.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | //swiftlint:disable line_length 10 | 11 | import UIKit 12 | import SwiftUI 13 | 14 | @UIApplicationMain 15 | class AppDelegate: UIResponder, UIApplicationDelegate { 16 | 17 | var window: UIWindow? 18 | var appCoordinator: AppCoordinator! 19 | 20 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 21 | window = UIWindow(frame: UIScreen.main.bounds) 22 | appCoordinator = AppCoordinator(window: window!) 23 | appCoordinator.start() 24 | return true 25 | } 26 | 27 | func applicationWillResignActive(_ application: UIApplication) { 28 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 29 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 30 | } 31 | 32 | func applicationDidEnterBackground(_ application: UIApplication) { 33 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 34 | } 35 | 36 | func applicationWillEnterForeground(_ application: UIApplication) { 37 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 38 | } 39 | 40 | func applicationDidBecomeActive(_ application: UIApplication) { 41 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-app-store.png", 5 | "idiom" : "tv" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-icon-app-store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/app-icon-app-store.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.imagestacklayer" 9 | }, 10 | { 11 | "filename" : "Back.imagestacklayer" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-app-store.png", 5 | "idiom" : "tv" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/app-icon-app-store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/app-icon-app-store.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "primary-app-icon.png", 5 | "idiom" : "tv", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "primary-app-icon@2x.png", 10 | "idiom" : "tv", 11 | "scale" : "2x" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/primary-app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/primary-app-icon.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/primary-app-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/primary-app-icon@2x.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.imagestacklayer" 9 | }, 10 | { 11 | "filename" : "Back.imagestacklayer" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "primary-app-icon.png", 5 | "idiom" : "tv", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "primary-app-icon@2x.png", 10 | "idiom" : "tv", 11 | "scale" : "2x" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/primary-app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/primary-app-icon.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/primary-app-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/primary-app-icon@2x.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets" : [ 3 | { 4 | "filename" : "App Icon - App Store.imagestack", 5 | "idiom" : "tv", 6 | "role" : "primary-app-icon", 7 | "size" : "1280x768" 8 | }, 9 | { 10 | "filename" : "App Icon.imagestack", 11 | "idiom" : "tv", 12 | "role" : "primary-app-icon", 13 | "size" : "400x240" 14 | }, 15 | { 16 | "filename" : "Top Shelf Image Wide.imageset", 17 | "idiom" : "tv", 18 | "role" : "top-shelf-image-wide", 19 | "size" : "2320x720" 20 | }, 21 | { 22 | "filename" : "Top Shelf Image.imageset", 23 | "idiom" : "tv", 24 | "role" : "top-shelf-image", 25 | "size" : "1920x720" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image Wide.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "top-shelf-image-wide.png", 5 | "idiom" : "tv", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "top-shelf-image-wide@2x.png", 10 | "idiom" : "tv", 11 | "scale" : "2x" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image Wide.imageset/top-shelf-image-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image Wide.imageset/top-shelf-image-wide.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image Wide.imageset/top-shelf-image-wide@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image Wide.imageset/top-shelf-image-wide@2x.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "top-shelf-image.png", 5 | "idiom" : "tv", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "top-shelf-image@2x.png", 10 | "idiom" : "tv", 11 | "scale" : "2x" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image.imageset/top-shelf-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image.imageset/top-shelf-image.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image.imageset/top-shelf-image@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTV/Resources/Assets.xcassets/Brand Assets.brandassets/Top Shelf Image.imageset/top-shelf-image@2x.png -------------------------------------------------------------------------------- /SwiftyTV/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /SwiftyTV/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIcons 10 | 11 | CFBundleIcons~ipad 12 | 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 21 | CFBundleShortVersionString 22 | 1.0 23 | CFBundleVersion 24 | 1 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIRequiredDeviceCapabilities 30 | 31 | arm64 32 | 33 | UIUserInterfaceStyle 34 | Automatic 35 | ITSAppUsesNonExemptEncryption 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /SwiftyTorrent.xcodeproj/xcshareddata/xcschemes/MediaKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /SwiftyTorrent.xcodeproj/xcshareddata/xcschemes/SwiftyTV.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /SwiftyTorrent.xcodeproj/xcshareddata/xcschemes/SwiftyTorrent.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /SwiftyTorrent.xcodeproj/xcshareddata/xcschemes/TorrentKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /SwiftyTorrent/Core/AppAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppAssembly.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 17.06.2021. 6 | // Copyright © 2021 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Swinject 11 | import TorrentKit 12 | 13 | private let swinjectContiner = Container() 14 | 15 | func registerDependencies() { 16 | swinjectContiner.register(TorrentManagerProtocol.self) { _ in TorrentManager.shared() } 17 | swinjectContiner.register(IMDBDataProviderProtocol.self) { _ in IMDBDataProvider() } 18 | swinjectContiner.register(EZTVDataProviderProtocol.self) { _ in EZTVDataProvider() } 19 | } 20 | 21 | func registerComponent(_ type: T.Type, resolver: @escaping () -> T) { 22 | swinjectContiner.register(type, factory: { _ in resolver() }) 23 | } 24 | 25 | func resolveComponent(_ type: T.Type) -> T { 26 | guard let service = swinjectContiner.resolve(type) else { 27 | fatalError("Missing dependency: \(type)") 28 | } 29 | return service 30 | } 31 | 32 | #if DEBUG 33 | func registerStubs() { 34 | registerComponent(TorrentManagerProtocol.self) { StubTorrentManager() } 35 | registerComponent(IMDBDataProviderProtocol.self) { StubIMDBDataProvider() } 36 | registerComponent(EZTVDataProviderProtocol.self) { StubEZTVDataProvider() } 37 | } 38 | #endif 39 | -------------------------------------------------------------------------------- /SwiftyTorrent/Core/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCoordinator.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/12/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Combine 12 | import TorrentKit 13 | 14 | protocol ApplicationCoordinator { 15 | 16 | func start() 17 | 18 | } 19 | 20 | final class AppCoordinator: ApplicationCoordinator { 21 | 22 | private var window: UIWindow! 23 | private var torrentManager: TorrentManagerProtocol { 24 | resolveComponent(TorrentManagerProtocol.self) 25 | } 26 | private var cancellables = [Cancellable]() 27 | 28 | init(window: UIWindow) { 29 | self.window = window 30 | 31 | cancellables.append(contentsOf: [ 32 | NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification) 33 | .sink { [unowned self] _ in 34 | self.registerBackgroundTask() 35 | }] 36 | ) 37 | } 38 | 39 | deinit { 40 | cancellables.forEach({ $0.cancel() }) 41 | } 42 | 43 | func handleOpenURLContexts(_ URLContexts: Set) { 44 | guard let URLContext = URLContexts.first else { return } 45 | torrentManager.open(URLContext.url) 46 | } 47 | 48 | // MARK: - ApplicationCoordinator 49 | 50 | func start() { 51 | registerDependencies() 52 | 53 | window.rootViewController = UIHostingController(rootView: MainView()) 54 | window.makeKeyAndVisible() 55 | 56 | requestUserNotifications() 57 | 58 | // Prevent screen from dimming 59 | UIApplication.shared.isIdleTimerDisabled = true 60 | } 61 | 62 | // MARK: - 63 | 64 | private func requestUserNotifications() { 65 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (_, error) in 66 | if let error = error { 67 | print("\(error.localizedDescription)") 68 | } 69 | } 70 | } 71 | 72 | // MARK: - Background 73 | 74 | private var backgroundTask: UIBackgroundTaskIdentifier = .invalid 75 | 76 | private func registerBackgroundTask() { 77 | backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in 78 | self?.endBackgroundTask() 79 | } 80 | assert(backgroundTask != .invalid) 81 | } 82 | 83 | private func endBackgroundTask() { 84 | print("Background task ended.") 85 | showLocalNotification() 86 | UIApplication.shared.endBackgroundTask(backgroundTask) 87 | backgroundTask = .invalid 88 | } 89 | 90 | private func showLocalNotification() { 91 | #if os(iOS) 92 | let content = UNMutableNotificationContent() 93 | content.title = "SwiftyTorrent" 94 | content.body = "Suspending session..." 95 | content.sound = UNNotificationSound.default 96 | let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) 97 | let request = UNNotificationRequest(identifier: "SuspendingSession", content: content, trigger: trigger) 98 | UNUserNotificationCenter.current().add(request) { (error: Error?) in 99 | if let error = error { 100 | print("\(error.localizedDescription)") 101 | } 102 | } 103 | #endif 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /SwiftyTorrent/Core/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/24/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, 15 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | // Override point for customization after application launch. 17 | return true 18 | } 19 | 20 | // MARK: UISceneSession Lifecycle 21 | 22 | @available(iOS 13.0, *) 23 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, 24 | options: UIScene.ConnectionOptions) -> UISceneConfiguration { 25 | // Called when a new scene session is being created. 26 | // Use this method to select a configuration to create the new scene with. 27 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 28 | } 29 | 30 | @available(iOS 13.0, *) 31 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 32 | // Called when the user discards a scene session. 33 | // If any sessions were discarded while the application was not running, 34 | // this will be called shortly after application:didFinishLaunchingWithOptions. 35 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /SwiftyTorrent/Core/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/24/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | @available(iOS 13.0, *) 13 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 14 | 15 | var window: UIWindow? 16 | var appCoordinator: AppCoordinator! 17 | 18 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, 19 | options connectionOptions: UIScene.ConnectionOptions) { 20 | // Use this method to optionally configure and attach the UIWindow `window` 21 | // to the provided UIWindowScene `scene`. 22 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 23 | // This delegate does not imply the connecting scene or session are new 24 | // (see `application:configurationForConnectingSceneSession` instead). 25 | 26 | // Use a UIHostingController as window root view controller 27 | if let windowScene = scene as? UIWindowScene { 28 | window = UIWindow(windowScene: windowScene) 29 | appCoordinator = AppCoordinator(window: window!) 30 | appCoordinator.start() 31 | } 32 | 33 | appCoordinator.handleOpenURLContexts(connectionOptions.urlContexts) 34 | } 35 | 36 | func sceneDidDisconnect(_ scene: UIScene) { 37 | // Called as the scene is being released by the system. 38 | // This occurs shortly after the scene enters the background, or when its session is discarded. 39 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 40 | // The scene may re-connect later, as its session was not necessarily discarded 41 | // (see `application:didDiscardSceneSessions` instead). 42 | } 43 | 44 | func sceneDidBecomeActive(_ scene: UIScene) { 45 | // Called when the scene has moved from an inactive state to an active state. 46 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 47 | } 48 | 49 | func sceneWillResignActive(_ scene: UIScene) { 50 | // Called when the scene will move from an active state to an inactive state. 51 | // This may occur due to temporary interruptions (ex. an incoming phone call). 52 | } 53 | 54 | func sceneWillEnterForeground(_ scene: UIScene) { 55 | // Called as the scene transitions from the background to the foreground. 56 | // Use this method to undo the changes made on entering the background. 57 | } 58 | 59 | func sceneDidEnterBackground(_ scene: UIScene) { 60 | // Called as the scene transitions from the foreground to the background. 61 | // Use this method to save data, release shared resources, and store enough scene-specific state information 62 | // to restore the scene back to its current state. 63 | } 64 | 65 | func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { 66 | appCoordinator.handleOpenURLContexts(URLContexts) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /SwiftyTorrent/Models/EZTVDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EZTVDataProvider.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 30.06.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | //swiftlint:disable nesting 10 | 11 | import Foundation 12 | import Combine 13 | 14 | protocol SearchDataItem { 15 | var id: String { get } 16 | var title: String { get } 17 | var sizeBytes: UInt64 { get } 18 | var episodeInfo: String? { get } 19 | var peersStatus: String { get } 20 | var magnetURL: URL { get } 21 | var details: String { get } 22 | } 23 | 24 | extension SearchDataItem { 25 | 26 | var size: String { 27 | ByteCountFormatter.string(fromByteCount: Int64(sizeBytes), countStyle: .binary) 28 | } 29 | 30 | var details: String { 31 | [episodeInfo, size, peersStatus] 32 | .compactMap { $0 } 33 | .joined(separator: ", ") 34 | } 35 | 36 | } 37 | 38 | extension EZTVDataProvider.Response.Torrent: SearchDataItem { 39 | 40 | var id: String { magnetURL.absoluteString } 41 | 42 | var episodeInfo: String? { 43 | guard let s = Int(season), let e = Int(episode) else { return nil } 44 | return String(format: "s%02de%02d", s, e) 45 | } 46 | 47 | var peersStatus: String { "seeds: \(seeds), peers: \(peers)" } 48 | 49 | } 50 | 51 | protocol EZTVDataProviderProtocol { 52 | func fetchTorrents(imdbId: String, page: Int) -> AnyPublisher<[SearchDataItem], Error> 53 | } 54 | 55 | final class EZTVDataProvider: EZTVDataProviderProtocol { 56 | 57 | static let endpoint = "https://eztv.re/api/" 58 | 59 | private let urlSession: URLSession = URLSession.shared 60 | private let endpointURL = URL(string: endpoint)! 61 | 62 | func fetchTorrents(imdbId: String, page: Int) -> AnyPublisher<[SearchDataItem], Error> { 63 | fetchTorrents(imdbId: imdbId, limit: 20, page: page) 64 | } 65 | 66 | private func fetchTorrents(imdbId: String, limit: Int, page: Int) -> AnyPublisher<[SearchDataItem], Error> { 67 | let requestURL = URL(string: endpointURL.absoluteString + 68 | "get-torrents?" + 69 | "limit=\(limit)&" + 70 | "page=\(page)&" + 71 | "imdb_id=\(imdbId)" 72 | )! 73 | return urlSession 74 | .dataTaskPublisher(for: requestURL) 75 | .tryMap({ data, response -> Data in 76 | guard let httpResponse = response as? HTTPURLResponse, 77 | httpResponse.statusCode == 200 else { 78 | throw URLError(.badServerResponse) 79 | } 80 | return data 81 | }) 82 | .decode(type: Response.self, decoder: JSONDecoder()) 83 | .map({ (response) -> [SearchDataItem] in 84 | print("torrentsCount: \(response.torrentsCount)") 85 | print("page: \(response.page)") 86 | print("page: \(response.limit)") 87 | return response.torrents 88 | }) 89 | .eraseToAnyPublisher() 90 | } 91 | } 92 | 93 | extension EZTVDataProvider { 94 | 95 | struct Response: Decodable { 96 | 97 | enum CodingKeys: String, CodingKey { 98 | case imdbId = "imdb_id" 99 | case torrentsCount = "torrents_count" 100 | case limit 101 | case page 102 | case torrents 103 | } 104 | 105 | let imdbId: String 106 | let torrentsCount: Int 107 | let limit: Int 108 | let page: Int 109 | let torrents: [Torrent] 110 | 111 | var hasMorePages: Bool { page * limit < torrentsCount } 112 | 113 | init(from decoder: Decoder) throws { 114 | let container = try decoder.container(keyedBy: CodingKeys.self) 115 | imdbId = try container.decode(String.self, forKey: .imdbId) 116 | torrentsCount = try container.decode(Int.self, forKey: .torrentsCount) 117 | limit = try container.decode(Int.self, forKey: .limit) 118 | page = try container.decode(Int.self, forKey: .page) 119 | var itemsContainer = try container.nestedUnkeyedContainer(forKey: .torrents) 120 | torrents = try itemsContainer.deocdeItems(ofType: Torrent.self) 121 | } 122 | 123 | struct Torrent: Decodable, CustomDebugStringConvertible { 124 | 125 | enum CodingKeys: String, CodingKey { 126 | case id 127 | case hash 128 | case fileName = "filename" 129 | case episodeURL = "episode_url" 130 | case torrentURL = "torrent_url" 131 | case magnetURL = "magnet_url" 132 | case title 133 | case imdbId = "imdb_id" 134 | case season 135 | case episode 136 | case smallThumb = "small_screenshot" 137 | case largeThumb = "large_screenshot" 138 | case seeds 139 | case peers 140 | case releaseDate = "date_released_unix" 141 | case sizeBytes = "size_bytes" 142 | } 143 | 144 | // let id: Int 145 | // let hash: String 146 | // let fileName: String 147 | // let episodeURL: URL 148 | let torrentURL: URL 149 | let magnetURL: URL 150 | let title: String 151 | // let imdbId: String 152 | let season: String 153 | let episode: String 154 | // let smallThumb: URL 155 | // let largeThumb: URL 156 | let seeds: Int 157 | let peers: Int 158 | // let releaseDate: TimeInterval 159 | let sizeBytes: UInt64 160 | 161 | var debugDescription: String { 162 | return title 163 | } 164 | 165 | init(from decoder: Decoder) throws { 166 | let container = try decoder.container(keyedBy: CodingKeys.self) 167 | if let rawValue = try? container.decode(String.self, forKey: .torrentURL), 168 | let encodedValue = rawValue.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), 169 | let URL = URL(string: encodedValue) { 170 | torrentURL = URL 171 | } else { 172 | throw "Bad torrentURL" 173 | } 174 | magnetURL = try container.decode(URL.self, forKey: .magnetURL) 175 | title = try container.decode(String.self, forKey: .title) 176 | season = try container.decode(String.self, forKey: .season) 177 | episode = try container.decode(String.self, forKey: .episode) 178 | seeds = try container.decode(Int.self, forKey: .seeds) 179 | peers = try container.decode(Int.self, forKey: .peers) 180 | if let rawValue = try? container.decode(String.self, forKey: .sizeBytes), 181 | let value = UInt64(rawValue) { 182 | sizeBytes = value 183 | } else { 184 | throw "Bad sizeBytes" 185 | } 186 | } 187 | 188 | } 189 | } 190 | } 191 | 192 | struct AnyDecodable: Decodable { } 193 | 194 | extension UnkeyedDecodingContainer { 195 | 196 | mutating func deocdeItems(ofType type: T.Type) throws -> [T] { 197 | var items = [T]() 198 | while !isAtEnd { 199 | do { 200 | let item = try decode(type) 201 | items.append(item) 202 | } catch let error { 203 | print("Failed to decode item: \(error)") 204 | // Skip item 205 | _ = try decode(AnyDecodable.self) 206 | } 207 | } 208 | return items 209 | } 210 | 211 | } 212 | -------------------------------------------------------------------------------- /SwiftyTorrent/Models/File+PreviewItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File+PreviewItem.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 02.07.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import MediaKit 11 | import TorrentKit 12 | 13 | extension File: PreviewItem { 14 | 15 | private var torrentManager: TorrentManagerProtocol { 16 | resolveComponent(TorrentManagerProtocol.self) 17 | } 18 | 19 | public var previewItemURL: URL? { 20 | return torrentManager 21 | .downloadsDirectoryURL() 22 | .appendingPathComponent(path) 23 | } 24 | 25 | public var previewItemTitle: String? { 26 | return title 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /SwiftyTorrent/Models/File+UTI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File+UTI.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 02.07.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UniformTypeIdentifiers 11 | 12 | extension File { 13 | 14 | private var fileExtension: String { 15 | return URL(fileURLWithPath: path).pathExtension 16 | } 17 | 18 | var isVideo: Bool { 19 | // Special handling for 'mkv' container 20 | switch fileExtension { 21 | case "mkv": return true 22 | default: break 23 | } 24 | // Other file extensions 25 | guard 26 | let mimeUTI = UTType(filenameExtension: fileExtension) 27 | else { return false } 28 | return mimeUTI.conforms(to: .audiovisualContent) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /SwiftyTorrent/Models/File.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/17/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import TorrentKit 11 | 12 | protocol FileProtocol: FileRowModel { 13 | 14 | var name: String { get } 15 | 16 | var path: String { get } 17 | 18 | func recursiveDescription(_ level: Int) 19 | 20 | } 21 | 22 | extension FileProtocol { 23 | 24 | var title: String { name } 25 | 26 | } 27 | 28 | public class File: NSObject, FileProtocol { 29 | 30 | let name: String 31 | let path: String 32 | var sizeDetails: String? 33 | 34 | init(name: String, path: String, size: UInt64) { 35 | self.name = name 36 | self.path = path 37 | self.sizeDetails = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) 38 | } 39 | 40 | public override var description: String { 41 | return name //+ " (\(path))" 42 | } 43 | 44 | func recursiveDescription(_ level: Int) { 45 | let tab = String(repeating: "\t", count: level) 46 | print(tab + "⎜" + description) 47 | } 48 | } 49 | 50 | extension File: Identifiable { 51 | 52 | public var id: String { path } 53 | 54 | } 55 | 56 | public class Directory: FileProtocol, CustomStringConvertible { 57 | 58 | let name: String 59 | let path: String 60 | var sizeDetails: String? 61 | 62 | var files: [FileProtocol] 63 | 64 | var allSubDirectories: [Directory] { 65 | //swiftlint:disable:next force_cast 66 | return files.filter({ type(of: $0) == Directory.self }) as! [Directory] 67 | } 68 | 69 | var allFiles: [File] { 70 | //swiftlint:disable:next force_cast 71 | return files.filter({ type(of: $0) == File.self }) as! [File] 72 | } 73 | 74 | init(name: String, path: String, files: [FileProtocol]? = nil) { 75 | self.name = name 76 | self.path = path 77 | self.files = files ?? [] 78 | } 79 | 80 | public var description: String { 81 | return name 82 | } 83 | 84 | func recursiveDescription(_ level: Int) { 85 | let tab = String(repeating: "\t", count: level) 86 | print(tab + "⎣" + description) 87 | 88 | // print all subdiectories first 89 | func nameOrder(lhs: FileProtocol, rhs: FileProtocol) -> Bool { 90 | return lhs.name < rhs.name 91 | } 92 | 93 | for dir in allSubDirectories.sorted(by: nameOrder) { 94 | dir.recursiveDescription(level + 1) 95 | } 96 | 97 | // all files after 98 | for file in allFiles.sorted(by: nameOrder) { 99 | file.recursiveDescription(level + 1) 100 | } 101 | } 102 | 103 | class func directory(from fileEntries: [FileEntry]) -> Directory { 104 | let rootDir = Directory(name: "/", path: "") 105 | for fileEntry in fileEntries { 106 | var lastDir = rootDir 107 | let filePath = fileEntry.path 108 | let components = filePath.components(separatedBy: "/") 109 | for (idx, component) in components.enumerated() { 110 | let isLast = (idx == components.count - 1) 111 | let path = lastDir.path + "/" + component 112 | if isLast { 113 | let file = File(name: component, path: path, size: fileEntry.size) 114 | lastDir.files.append(file) 115 | } else { 116 | var dir: Directory! = lastDir.files.first(where: { $0.name == component }) as? Directory 117 | if dir == nil { 118 | dir = Directory(name: component, path: path) 119 | lastDir.files.append(dir) 120 | } 121 | lastDir = dir 122 | } 123 | } 124 | } 125 | return rootDir 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /SwiftyTorrent/Models/IMDBDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IMDBDataProvider.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 30.06.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | //swiftlint:disable nesting 10 | 11 | import Foundation 12 | import Combine 13 | 14 | extension String: Error { } 15 | 16 | protocol IMDBDataProviderProtocol { 17 | func fetchSuggestions(_ query: String) -> AnyPublisher 18 | } 19 | 20 | final class IMDBDataProvider: IMDBDataProviderProtocol { 21 | 22 | private let urlSession: URLSession = URLSession.shared 23 | private let endpointURL = URL(string: "https://sg.media-imdb.com/")! 24 | 25 | private var cache = [String: String]() 26 | 27 | func fetchSuggestions(_ query: String) -> AnyPublisher { 28 | let query = query.lowercased() 29 | // Cache look-up 30 | if let imdbId = cache[query] { 31 | return Just(imdbId) 32 | .setFailureType(to: Error.self) 33 | .eraseToAnyPublisher() 34 | } 35 | // Constructs path like: `suggests/s/simpsons.json` 36 | let requestURL = endpointURL.appendingPathComponent("suggests/\(query.prefix(1))/\(query).json") 37 | return urlSession 38 | .dataTaskPublisher(for: requestURL) 39 | .tryMap { data, response -> Data in 40 | guard let httpResponse = response as? HTTPURLResponse, 41 | httpResponse.statusCode == 200 else { 42 | throw URLError(.badServerResponse) 43 | } 44 | return data 45 | } 46 | .tryMap { (data) -> Data in 47 | // Strip json-p padding `imdb$search_query()` 48 | var value = String(bytes: data, encoding: .utf8)! 49 | if let idx = value.firstIndex(of: "(") { 50 | value.removeSubrange(value.startIndex.. String in 58 | guard var imdbId = imdbResponse.data.first?.id else { 59 | throw "IMDB show not found." 60 | } 61 | imdbId = String(imdbId.dropFirst(2)) // remove "tt" 62 | return imdbId 63 | } 64 | .handleEvents(receiveOutput: { imdbId in 65 | // Cache `imdbId` 66 | self.cache[query] = imdbId 67 | }) 68 | .eraseToAnyPublisher() 69 | } 70 | 71 | } 72 | 73 | extension IMDBDataProvider { 74 | 75 | struct Response: Decodable { 76 | 77 | enum CodingKeys: String, CodingKey { 78 | case version = "v" 79 | case query = "q" 80 | case data = "d" 81 | } 82 | 83 | let version: Int 84 | let query: String 85 | let data: [DataItem] 86 | 87 | init(from decoder: Decoder) throws { 88 | let values = try decoder.container(keyedBy: CodingKeys.self) 89 | version = try values.decode(Int.self, forKey: .version) 90 | query = try values.decode(String.self, forKey: .query) 91 | data = try values.decode([DataItem].self, forKey: .data) 92 | } 93 | 94 | struct DataItem: Decodable { 95 | 96 | enum CodingKeys: String, CodingKey { 97 | case label = "l" 98 | case id = "id" 99 | } 100 | 101 | let label: String 102 | let id: String 103 | 104 | init(from decoder: Decoder) throws { 105 | let values = try decoder.container(keyedBy: CodingKeys.self) 106 | label = try values.decode(String.self, forKey: .label) 107 | id = try values.decode(String.self, forKey: .id) 108 | } 109 | 110 | } 111 | 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /SwiftyTorrent/Models/Torrent+Files.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Torrent.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/15/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import TorrentKit 10 | 11 | extension Torrent { 12 | 13 | private var torrentManager: TorrentManagerProtocol { 14 | resolveComponent(TorrentManagerProtocol.self) 15 | } 16 | 17 | private static var filesCache = [Data: [FileEntry]]() 18 | private static var dirsCache = [Data: Directory]() 19 | 20 | private var fileEntries: [FileEntry] { 21 | if Torrent.filesCache[infoHash] == nil { 22 | Torrent.filesCache[infoHash] = torrentManager.filesForTorrent(withHash: infoHash) 23 | } 24 | return Torrent.filesCache[infoHash]! 25 | } 26 | 27 | var directory: Directory { 28 | if Torrent.dirsCache[infoHash] == nil { 29 | let dir = Directory.directory(from: fileEntries) 30 | Torrent.dirsCache[infoHash] = dir 31 | } 32 | return Torrent.dirsCache[infoHash]! 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "icon_60pt@2x.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "60x60" 38 | }, 39 | { 40 | "filename" : "icon_60pt@3x.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "idiom" : "ipad", 47 | "scale" : "1x", 48 | "size" : "20x20" 49 | }, 50 | { 51 | "idiom" : "ipad", 52 | "scale" : "2x", 53 | "size" : "20x20" 54 | }, 55 | { 56 | "idiom" : "ipad", 57 | "scale" : "1x", 58 | "size" : "29x29" 59 | }, 60 | { 61 | "idiom" : "ipad", 62 | "scale" : "2x", 63 | "size" : "29x29" 64 | }, 65 | { 66 | "idiom" : "ipad", 67 | "scale" : "1x", 68 | "size" : "40x40" 69 | }, 70 | { 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "40x40" 74 | }, 75 | { 76 | "idiom" : "ipad", 77 | "scale" : "1x", 78 | "size" : "76x76" 79 | }, 80 | { 81 | "filename" : "icon_76pt@2x.png", 82 | "idiom" : "ipad", 83 | "scale" : "2x", 84 | "size" : "76x76" 85 | }, 86 | { 87 | "filename" : "icon_83.5@2x.png", 88 | "idiom" : "ipad", 89 | "scale" : "2x", 90 | "size" : "83.5x83.5" 91 | }, 92 | { 93 | "filename" : "Icon.png", 94 | "idiom" : "ios-marketing", 95 | "scale" : "1x", 96 | "size" : "1024x1024" 97 | } 98 | ], 99 | "info" : { 100 | "author" : "xcode", 101 | "version" : 1 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/Icon.png -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_60pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_60pt@2x.png -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_60pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_60pt@3x.png -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_76pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_76pt@2x.png -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTorrent/Resources/Assets.xcassets/AppIcon.appiconset/icon_83.5@2x.png -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Icons/uTorrent-320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danylokos/SwiftyTorrent/61876fc04258cff10ba9872a92d6062dfae6568c/SwiftyTorrent/Resources/Icons/uTorrent-320.png -------------------------------------------------------------------------------- /SwiftyTorrent/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDocumentTypes 8 | 9 | 10 | CFBundleTypeIconFiles 11 | 12 | uTorrent-320.png 13 | 14 | CFBundleTypeName 15 | Torrent 16 | CFBundleTypeRole 17 | Viewer 18 | LSHandlerRank 19 | Default 20 | LSItemContentTypes 21 | 22 | $(PRODUCT_BUNDLE_IDENTIFIER).torrent 23 | 24 | 25 | 26 | CFBundleExecutable 27 | $(EXECUTABLE_NAME) 28 | CFBundleIdentifier 29 | $(PRODUCT_BUNDLE_IDENTIFIER) 30 | CFBundleInfoDictionaryVersion 31 | 6.0 32 | CFBundleName 33 | $(PRODUCT_NAME) 34 | CFBundlePackageType 35 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 36 | CFBundleShortVersionString 37 | 1.0 38 | CFBundleURLTypes 39 | 40 | 41 | CFBundleTypeRole 42 | Viewer 43 | CFBundleURLIconFile 44 | uTorrent-320 45 | CFBundleURLName 46 | $(PRODUCT_BUNDLE_IDENTIFIER).magnet 47 | CFBundleURLSchemes 48 | 49 | magnet 50 | 51 | 52 | 53 | CFBundleVersion 54 | 1 55 | LSRequiresIPhoneOS 56 | 57 | LSSupportsOpeningDocumentsInPlace 58 | 59 | UIApplicationSceneManifest 60 | 61 | UIApplicationSupportsMultipleScenes 62 | 63 | UISceneConfigurations 64 | 65 | UIWindowSceneSessionRoleApplication 66 | 67 | 68 | UILaunchStoryboardName 69 | LaunchScreen 70 | UISceneConfigurationName 71 | Default Configuration 72 | UISceneDelegateClassName 73 | $(PRODUCT_MODULE_NAME).SceneDelegate 74 | 75 | 76 | 77 | 78 | UIBackgroundModes 79 | 80 | processing 81 | 82 | UIFileSharingEnabled 83 | 84 | UILaunchStoryboardName 85 | LaunchScreen 86 | UIRequiredDeviceCapabilities 87 | 88 | armv7 89 | 90 | UISupportedInterfaceOrientations 91 | 92 | UIInterfaceOrientationPortrait 93 | UIInterfaceOrientationLandscapeLeft 94 | UIInterfaceOrientationLandscapeRight 95 | 96 | UISupportedInterfaceOrientations~ipad 97 | 98 | UIInterfaceOrientationPortrait 99 | UIInterfaceOrientationPortraitUpsideDown 100 | UIInterfaceOrientationLandscapeLeft 101 | UIInterfaceOrientationLandscapeRight 102 | 103 | UTImportedTypeDeclarations 104 | 105 | 106 | UTTypeConformsTo 107 | 108 | public.data 109 | 110 | UTTypeDescription 111 | Torrent 112 | UTTypeIconFiles 113 | 114 | uTorrent-320.png 115 | 116 | UTTypeIdentifier 117 | $(PRODUCT_BUNDLE_IDENTIFIER).torrent 118 | UTTypeTagSpecification 119 | 120 | public.filename-extension 121 | 122 | torrent 123 | 124 | 125 | 126 | 127 | BGTaskSchedulerPermittedIdentifiers 128 | 129 | $(PRODUCT_BUNDLE_IDENTIFIER) 130 | 131 | ITSAppUsesNonExemptEncryption 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /SwiftyTorrent/Stubs/StubEZTVDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubEZTVDataProvider.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 18.06.2021. 6 | // Copyright © 2021 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | struct StubSearchDataItem: SearchDataItem { 13 | 14 | static var idx = 0 15 | 16 | var id: String 17 | var title: String 18 | var sizeBytes: UInt64 19 | var episodeInfo: String? 20 | var peersStatus: String 21 | var magnetURL: URL 22 | 23 | static func randomStub() -> Self { 24 | defer { Self.idx += 1 } 25 | return StubSearchDataItem( 26 | id: UUID().uuidString, 27 | title: "Stub search item (\(Self.idx))", 28 | sizeBytes: UInt64.random(in: 0..<(2<<40)), 29 | episodeInfo: "s01e01", 30 | peersStatus: "seeds: 2, peers: 2", 31 | magnetURL: URL(string: "magnet:?xt=magnet.test")! 32 | ) 33 | } 34 | } 35 | 36 | class StubEZTVDataProvider: EZTVDataProviderProtocol { 37 | 38 | func fetchTorrents(imdbId: String, page: Int) -> AnyPublisher<[SearchDataItem], Error> { 39 | let items = (0..<10).map { _ in StubSearchDataItem.randomStub() } 40 | return Just(items) 41 | .setFailureType(to: Error.self) 42 | .eraseToAnyPublisher() 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /SwiftyTorrent/Stubs/StubIMDBDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubIMDBDataProvider.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 18.06.2021. 6 | // Copyright © 2021 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | class StubIMDBDataProvider: IMDBDataProviderProtocol { 13 | 14 | func fetchSuggestions(_ query: String) -> AnyPublisher { 15 | return Just("stubImdbId") 16 | .setFailureType(to: Error.self) 17 | .eraseToAnyPublisher() 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /SwiftyTorrent/Stubs/StubTorrentManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubTorrentManager.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 18.06.2021. 6 | // Copyright © 2021 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import TorrentKit 11 | 12 | #if DEBUG 13 | class StubTorrentManager: TorrentManagerProtocol { 14 | 15 | var isSessionActive = true 16 | 17 | func addDelegate(_ delegate: TorrentManagerDelegate) { } 18 | 19 | func removeDelegate(_ delegate: TorrentManagerDelegate) { } 20 | 21 | func restoreSession() { } 22 | 23 | func add(_ torrent: STDownloadable) -> Bool { true } 24 | 25 | func removeTorrent(withInfoHash infoHash: Data, deleteFiles: Bool) -> Bool { true } 26 | 27 | func removeAllTorrents(withFiles deleteFiles: Bool) -> Bool { true } 28 | 29 | func torrents() -> [Torrent] { (0..<10).map { _ in Torrent.randomStub() } } 30 | 31 | func open(_ URL: URL) { } 32 | 33 | func filesForTorrent(withHash infoHash: Data) -> [FileEntry] { [] } 34 | 35 | func downloadsDirectoryURL() -> URL { 36 | URL(fileURLWithPath: NSTemporaryDirectory()) 37 | } 38 | 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/FileRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileRow.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/16/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FileRow: View { 12 | 13 | var model: FileRowModel 14 | 15 | var body: some View { 16 | VStack(alignment: .leading) { 17 | Text(model.title) 18 | .font(.headline) 19 | .bold() 20 | .lineLimit(2) 21 | if let deltails = model.sizeDetails { 22 | Spacer(minLength: 5) 23 | Text(deltails) 24 | .font(.subheadline) 25 | .foregroundColor(.gray) 26 | } 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/FileRowModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileRowModel.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/16/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import TorrentKit 11 | 12 | protocol FileRowModel { 13 | 14 | var title: String { get } 15 | 16 | var sizeDetails: String? { get } 17 | 18 | } 19 | 20 | extension FileEntry: FileRowModel { 21 | 22 | var title: String { name } 23 | 24 | var sizeDetails: String? { 25 | ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/FilesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilesView.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/16/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import MediaKit 11 | 12 | struct FilesView: View { 13 | 14 | var model: FilesViewModel 15 | @State var selectedItem: File? 16 | @State var selectedVideo: File? 17 | 18 | var body: some View { 19 | List { 20 | ForEach(model.directory.allSubDirectories, id: \.path) { subDir in 21 | NavigationLink(destination: FilesView(model: subDir)) { 22 | FileRow(model: subDir) 23 | } 24 | } 25 | ForEach(model.directory.allFiles, id: \.path) { item in 26 | Button(action: { 27 | if item.isVideo { 28 | self.selectedVideo = item 29 | } else { 30 | self.selectedItem = item 31 | } 32 | }) { 33 | FileRow(model: item) 34 | } 35 | } 36 | }.listStyle(PlainListStyle()) 37 | .truncationMode(.middle) 38 | #if os(iOS) 39 | .navigationBarTitle(model.title, displayMode: .inline) 40 | #endif 41 | .sheet(item: $selectedItem) { item in 42 | NavigationView { 43 | Group { 44 | #if os(iOS) 45 | QLViewHost(previewItem: item) 46 | #else 47 | Text("Not Supported") 48 | Spacer() 49 | Text(item.name) 50 | #endif 51 | } 52 | .navigationBarItems(leading: Button("Done") { selectedItem = nil }) 53 | #if os(iOS) 54 | .navigationBarTitle(item.name, displayMode: .inline) 55 | #endif 56 | } 57 | } 58 | .fullScreenCover(item: $selectedVideo) { item in 59 | VLCViewHost(previewItem: item) 60 | .edgesIgnoringSafeArea(.all) 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/FilesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilesViewModel.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/16/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import TorrentKit 11 | 12 | protocol FilesViewModel { 13 | 14 | var title: String { get } 15 | 16 | var directory: Directory { get } 17 | 18 | } 19 | 20 | extension Directory: FilesViewModel { 21 | 22 | var title: String { name } 23 | 24 | var directory: Directory { self } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 29.06.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MainView: View { 12 | 13 | var body: some View { 14 | TabView { 15 | TorrentsView(model: TorrentsViewModel()) 16 | .tabItem { 17 | Image(systemName: "square.and.arrow.down") 18 | Text("Torrents") 19 | } 20 | SearchView(model: SearchViewModel()) 21 | .tabItem { 22 | Image(systemName: "magnifyingglass") 23 | Text("Search") 24 | } 25 | SettingsView(model: SettingsViewModel()) 26 | .tabItem { 27 | Image(systemName: "gear") 28 | Text("Settings") 29 | } 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/SearchRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchRow.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 30.06.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SearchRow: View { 12 | 13 | var model: SearchDataItem 14 | var action: () -> Void 15 | 16 | var body: some View { 17 | Button(action: action) { 18 | VStack(alignment: .leading) { 19 | Text(model.title) 20 | .font(Font.headline) 21 | .bold() 22 | .lineLimit(2) 23 | Spacer(minLength: 5) 24 | Text("\(model.size), \(model.details)") 25 | .font(Font.subheadline) 26 | } 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/SearchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchView.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 29.06.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import TorrentKit 11 | 12 | struct SearchView: View { 13 | 14 | @ObservedObject var model: SearchViewModel 15 | 16 | var body: some View { 17 | NavigationView { 18 | List { 19 | ForEach(model.items, id: \.title) { item in 20 | SearchRow(model: item) { 21 | print("select: \(item.title)") 22 | model.select(item) 23 | }.onAppear(perform: { 24 | model.loadMoreIfNeeded(currentItem: item) 25 | }) 26 | } 27 | if model.isLoading { 28 | HStack { 29 | Spacer() 30 | ProgressView("Loading...") 31 | Spacer() 32 | } 33 | } 34 | } 35 | .listStyle(PlainListStyle()) 36 | .searchable(text: $model.searchText, prompt: "Search...") 37 | .navigationBarTitle("Search") 38 | } 39 | } 40 | 41 | } 42 | 43 | #if DEBUG 44 | struct SearchView_Previews: PreviewProvider { 45 | static var previews: some View { 46 | // Use stubs 47 | registerStubs() 48 | return SearchView(model: SearchViewModel()) 49 | } 50 | } 51 | #endif 52 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/SearchViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewModel.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 29.06.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | import TorrentKit 12 | 13 | final class SearchViewModel: ObservableObject { 14 | 15 | @Published var searchText: String = "" 16 | @Published var isLoading: Bool = false 17 | @Published var items = [SearchDataItem]() 18 | 19 | private var currentPage = 1 20 | private var hasMorePages = false 21 | 22 | private let imdbProvider = resolveComponent(IMDBDataProviderProtocol.self) 23 | private let eztbProvider = resolveComponent(EZTVDataProviderProtocol.self) 24 | private let torrentManager = resolveComponent(TorrentManagerProtocol.self) 25 | 26 | private var cancellables = [AnyCancellable]() 27 | 28 | init() { 29 | $searchText 30 | .handleEvents(receiveOutput: { text in 31 | // Clear results if `searchText` is empty 32 | if text.isEmpty { 33 | self.items = [] 34 | } 35 | }) 36 | .filter { !$0.isEmpty } 37 | .debounce(for: .milliseconds(500), scheduler: RunLoop.main) 38 | .handleEvents(receiveOutput: { _ in 39 | self.isLoading = true 40 | self.currentPage = 1 41 | self.hasMorePages = true 42 | }) 43 | .map { 44 | self.imdbProvider.fetchSuggestions($0) 45 | .replaceError(with: "") 46 | } 47 | .switchToLatest() 48 | .map { 49 | self.eztbProvider.fetchTorrents(imdbId: $0, page: 1) 50 | .replaceError(with: []) 51 | } 52 | .switchToLatest() 53 | .receive(on: DispatchQueue.main) 54 | .handleEvents(receiveOutput: { _ in 55 | self.isLoading = false 56 | }) 57 | .assign(to: \.items, on: self) 58 | .store(in: &cancellables) 59 | } 60 | 61 | deinit { 62 | cancellables.forEach { $0.cancel() } 63 | } 64 | 65 | // MARK: - 66 | 67 | func loadMoreIfNeeded(currentItem item: SearchDataItem) { 68 | let thresholdIdx = items.index(items.endIndex, offsetBy: -5) 69 | if items.firstIndex(where: { $0.id == item.id }) == thresholdIdx { 70 | loadMore() 71 | } 72 | } 73 | 74 | private func loadMore() { 75 | guard !isLoading && hasMorePages else { return } 76 | 77 | isLoading = true 78 | 79 | imdbProvider.fetchSuggestions(searchText) 80 | .map { 81 | self.eztbProvider.fetchTorrents(imdbId: $0, page: self.currentPage + 1) 82 | } 83 | .switchToLatest() 84 | .receive(on: DispatchQueue.main) 85 | .handleEvents(receiveOutput: { items in 86 | self.isLoading = false 87 | self.currentPage += 1 88 | self.hasMorePages = !items.isEmpty 89 | }) 90 | .map { items in 91 | return self.items + items 92 | } 93 | .catch({ _ in Just(self.items) }) 94 | .assign(to: \.items, on: self) 95 | .store(in: &cancellables) 96 | } 97 | 98 | func select(_ item: SearchDataItem) { 99 | let magnetURI = MagnetURI(magnetURI: item.magnetURL) 100 | torrentManager.add(magnetURI) 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 15.06.2021. 6 | // Copyright © 2021 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SettingsView: View { 12 | 13 | @ObservedObject var model: SettingsViewModel 14 | 15 | var body: some View { 16 | NavigationView { 17 | List { 18 | Section("Search") { 19 | SettingsRow(title: "EZTV enpoint", value: $model.eztvEndpoint) 20 | } 21 | Section("Storage") { 22 | SettingsRow(title: "Available", value: $model.availableDiskSpace) 23 | SettingsRow(title: "Downloads size", value: $model.usedDiskSpace) 24 | SettingsActionRow(title: "Remove all downloads", role: .destructive) { 25 | model.removeAllDownloads() 26 | } 27 | } 28 | Section("About") { 29 | SettingsRow(title: "Version", value: $model.appVersion) 30 | } 31 | } 32 | .onAppear { model.reloadData() } 33 | .refreshable { model.reloadData() } 34 | .listStyle(GroupedListStyle()) 35 | .navigationTitle("Settings") 36 | } 37 | } 38 | 39 | } 40 | 41 | struct SettingsRow: View { 42 | 43 | let title: String 44 | @Binding var value: String 45 | 46 | var body: some View { 47 | HStack { 48 | Text(title) 49 | Spacer() 50 | Text(value) 51 | .foregroundColor(.gray) 52 | } 53 | } 54 | 55 | } 56 | 57 | struct SettingsActionRow: View { 58 | 59 | let title: String 60 | let role: ButtonRole? 61 | let action: () -> Void 62 | 63 | var body: some View { 64 | HStack { 65 | Spacer() 66 | Button(title, role: role, action: action) 67 | Spacer() 68 | } 69 | } 70 | 71 | } 72 | 73 | #if DEBUG 74 | struct SettingsView_Previews: PreviewProvider { 75 | static var previews: some View { 76 | SettingsView(model: SettingsViewModel()) 77 | } 78 | } 79 | #endif 80 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModel.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 15.06.2021. 6 | // Copyright © 2021 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import TorrentKit 11 | 12 | final class SettingsViewModel: ObservableObject { 13 | 14 | @Published var availableDiskSpace: String = "N\\A" 15 | @Published var usedDiskSpace: String = "N\\A" 16 | 17 | @Published var eztvEndpoint: String = EZTVDataProvider.endpoint 18 | 19 | @Published var appVersion: String = { 20 | guard 21 | let infoDict = Bundle.main.infoDictionary, 22 | let appVer = infoDict["CFBundleShortVersionString"] as? String, 23 | let buildVer = infoDict["CFBundleVersion"] as? String 24 | else { return "N\\A" } 25 | return "\(appVer) (\(buildVer))" 26 | }() 27 | 28 | private let torrentManager = resolveComponent(TorrentManagerProtocol.self) 29 | 30 | init() { 31 | availableDiskSpace = calcAvailableDiskSpace() 32 | usedDiskSpace = calcUsedDiskSpace() 33 | } 34 | 35 | // MARK: - 36 | 37 | private func calcAvailableDiskSpace() -> String { 38 | let downloadsURL = torrentManager.downloadsDirectoryURL() 39 | guard 40 | let attrs = try? FileManager.default.attributesOfFileSystem(forPath: downloadsURL.path), 41 | let value = attrs[.systemFreeSize] as? NSNumber 42 | else { return "N\\A" } 43 | return ByteCountFormatter.string(fromByteCount: value.int64Value, countStyle: .file) 44 | } 45 | 46 | private func calcUsedDiskSpace() -> String { 47 | let downloadsURL = torrentManager.downloadsDirectoryURL() 48 | guard 49 | let subpaths = try? FileManager.default.subpathsOfDirectory(atPath: downloadsURL.path) 50 | else { return "N\\A" } 51 | 52 | var totalSize: Int64 = 0 53 | for fileName in subpaths { 54 | guard 55 | let attrs = try? FileManager.default.attributesOfItem( 56 | atPath: downloadsURL.appendingPathComponent(fileName).path 57 | ), 58 | let value = attrs[.size] as? NSNumber 59 | else { continue } 60 | totalSize += value.int64Value 61 | } 62 | return ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) 63 | } 64 | 65 | // MARK: - 66 | 67 | func reloadData() { 68 | availableDiskSpace = calcAvailableDiskSpace() 69 | usedDiskSpace = calcUsedDiskSpace() 70 | } 71 | 72 | func removeAllDownloads() { 73 | defer { 74 | availableDiskSpace = calcAvailableDiskSpace() 75 | usedDiskSpace = calcUsedDiskSpace() 76 | } 77 | 78 | // Remove all torrents 79 | torrentManager.removeAllTorrents(withFiles: true) 80 | 81 | // Remove file leftovers 82 | let downloadsURL = torrentManager.downloadsDirectoryURL() 83 | guard 84 | let contents = try? FileManager.default.contentsOfDirectory( 85 | at: downloadsURL, 86 | includingPropertiesForKeys: nil, 87 | options: .skipsHiddenFiles 88 | ) 89 | else { return } 90 | for fileURL in contents { 91 | do { 92 | try FileManager.default.removeItem(at: fileURL) 93 | } catch let error { 94 | print(error) 95 | } 96 | } 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/TorrentRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentRow.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/13/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TorrentRow: View { 12 | 13 | var model: TorrentRowModel 14 | 15 | var body: some View { 16 | VStack(alignment: .leading) { 17 | Text(model.title) 18 | .font(Font.headline) 19 | .bold() 20 | .lineLimit(2) 21 | Spacer(minLength: 5) 22 | Text(model.statusDetails) 23 | .font(Font.subheadline) 24 | Spacer(minLength: 5) 25 | Text(model.connectionDetails) 26 | .font(Font.subheadline) 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/TorrentRowModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Torrent+Cell.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/12/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import TorrentKit 11 | 12 | protocol TorrentRowModel { 13 | 14 | var title: String { get } 15 | 16 | var statusDetails: String { get } 17 | 18 | var connectionDetails: String { get } 19 | 20 | } 21 | 22 | extension Torrent: TorrentRowModel { 23 | 24 | var title: String { 25 | return name 26 | } 27 | 28 | var statusDetails: String { 29 | let progressString = String(format: "%0.2f %%", progress * 100) 30 | return "\(state.symbol) \(state), \(progressString), seeds: \(numberOfSeeds), peers: \(numberOfPeers)" 31 | } 32 | 33 | var connectionDetails: String { 34 | let downloadRateString = ByteCountFormatter.string(fromByteCount: Int64(downloadRate), countStyle: .binary) 35 | let uploadRateString = ByteCountFormatter.string(fromByteCount: Int64(uploadRate), countStyle: .binary) 36 | return "↓ \(downloadRateString), ↑ \(uploadRateString)" 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/TorrentsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentsView.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/1/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import TorrentKit 12 | 13 | struct TorrentsView: View { 14 | 15 | @ObservedObject var model: TorrentsViewModel 16 | 17 | var body: some View { 18 | NavigationView { 19 | List { 20 | Section(header: Text("Downloads")) { 21 | ForEach(model.torrents, id: \.infoHash) { torrent in 22 | NavigationLink(destination: FilesView(model: torrent.directory)) { 23 | TorrentRow(model: torrent) 24 | }.contextMenu { 25 | Button(role: .destructive) { model.remove(torrent) } label: { 26 | Label("Remove torrent", systemImage: "trash") 27 | } 28 | Button(role: .destructive) { model.remove(torrent, deleteFiles: true) } label: { 29 | Label("Remove all data", systemImage: "trash") 30 | } 31 | }.disabled(!torrent.hasMetadata) 32 | } 33 | } 34 | #if DEBUG 35 | Section(header: Text("Debug")) { 36 | Button("Add test torrent files") { 37 | model.addTestTorrentFiles() 38 | } 39 | Button("Add test magnet links") { 40 | model.addTestMagnetLinks() 41 | } 42 | Button("Add all test torrents") { 43 | model.addTestTorrents() 44 | } 45 | } 46 | #if os(iOS) 47 | .buttonStyle(BlueButton()) 48 | #endif 49 | #endif 50 | } 51 | .refreshable { model.reloadData() } 52 | .listStyle(PlainListStyle()) 53 | .navigationBarTitle("Torrents") 54 | } 55 | .alert(isPresented: model.isPresentingAlert) { () -> Alert in 56 | Alert(error: model.activeError!) 57 | } 58 | } 59 | 60 | } 61 | 62 | struct BlueButton: ButtonStyle { 63 | func makeBody(configuration: Configuration) -> some View { 64 | configuration.label 65 | .foregroundColor(.blue) 66 | } 67 | } 68 | 69 | extension Alert { 70 | init(error: Error) { 71 | self = Alert( 72 | title: Text("Error"), 73 | message: Text(error.localizedDescription), 74 | dismissButton: .default(Text("OK")) 75 | ) 76 | } 77 | } 78 | 79 | #if DEBUG 80 | struct TorrentsView_Previews: PreviewProvider { 81 | static var previews: some View { 82 | // Use stubs 83 | registerStubs() 84 | let model = TorrentsViewModel() 85 | return TorrentsView(model: model).environment(\.colorScheme, .dark) 86 | } 87 | } 88 | #endif 89 | -------------------------------------------------------------------------------- /SwiftyTorrent/UI/TorrentsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BindableTorrentManager.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/12/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | import TorrentKit 12 | 13 | final class TorrentsViewModel: NSObject, ObservableObject, TorrentManagerDelegate { 14 | 15 | private let torrentManager = resolveComponent(TorrentManagerProtocol.self) 16 | 17 | private(set) var torrents = [Torrent]() 18 | 19 | private let torrentsWillChangeSubject = PassthroughSubject() 20 | 21 | var objectWillChange: AnyPublisher 22 | 23 | @Published private(set) var activeError: Error? 24 | 25 | var isPresentingAlert: Binding { 26 | return Binding(get: { 27 | return self.activeError != nil 28 | }, set: { newValue in 29 | guard !newValue else { return } 30 | self.activeError = nil 31 | }) 32 | } 33 | 34 | override init() { 35 | objectWillChange = torrentsWillChangeSubject 36 | .throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true) 37 | .receive(on: DispatchQueue.main) 38 | .eraseToAnyPublisher() 39 | 40 | super.init() 41 | torrentManager.addDelegate(self) 42 | reloadData() 43 | } 44 | 45 | func reloadData() { 46 | torrentsWillChangeSubject.send() 47 | torrents = torrentManager.torrents() 48 | .sorted(by: { $0.name < $1.name }) 49 | } 50 | 51 | func remove(_ torrent: Torrent, deleteFiles: Bool = false) { 52 | torrentManager.removeTorrent(withInfoHash: torrent.infoHash, deleteFiles: deleteFiles) 53 | } 54 | 55 | // MARK: - TorrentManagerDelegate 56 | 57 | func torrentManager(_ manager: TorrentManager, didAdd torrent: Torrent) { 58 | reloadData() 59 | } 60 | 61 | func torrentManager(_ manager: TorrentManager, didRemoveTorrentWithHash hashData: Data) { 62 | reloadData() 63 | } 64 | 65 | func torrentManager(_ manager: TorrentManager, didReceiveUpdateFor torrent: Torrent) { 66 | reloadData() 67 | } 68 | 69 | func torrentManager(_ manager: TorrentManager, didErrorOccur error: Error) { 70 | DispatchQueue.main.async { 71 | self.activeError = error 72 | } 73 | } 74 | 75 | } 76 | 77 | #if DEBUG 78 | extension TorrentsViewModel { 79 | 80 | func addTestTorrentFiles() { 81 | torrentManager.add(TorrentFile.test_1()) 82 | torrentManager.add(TorrentFile.test_2()) 83 | } 84 | 85 | func addTestMagnetLinks() { 86 | torrentManager.add(MagnetURI.test_1()) 87 | } 88 | 89 | func addTestTorrents() { 90 | addTestTorrentFiles() 91 | addTestMagnetLinks() 92 | } 93 | 94 | } 95 | #endif 96 | -------------------------------------------------------------------------------- /TorrentKit/Core/STDownloadable.h: -------------------------------------------------------------------------------- 1 | // 2 | // STDownloadable.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/29/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | @protocol STDownloadable 14 | 15 | - (void)configureAddTorrentParams:(void *)params; // lt::add_torrent_params * 16 | 17 | @end 18 | 19 | NS_ASSUME_NONNULL_END 20 | -------------------------------------------------------------------------------- /TorrentKit/Core/STFileEntry.h: -------------------------------------------------------------------------------- 1 | // 2 | // STFileEntry.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/15/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | NS_SWIFT_NAME(FileEntry) 14 | @interface STFileEntry : NSObject 15 | @property (readonly, strong, nonatomic) NSString *name; 16 | @property (readonly, strong, nonatomic) NSString *path; 17 | @property (readonly, nonatomic) uint64_t size; 18 | @end 19 | 20 | NS_ASSUME_NONNULL_END 21 | -------------------------------------------------------------------------------- /TorrentKit/Core/STFileEntry.m: -------------------------------------------------------------------------------- 1 | // 2 | // STFileEntry.m 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/15/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import "STFileEntry.h" 10 | 11 | @interface STFileEntry () 12 | @property (readwrite, strong, nonatomic) NSString *name; 13 | @property (readwrite, strong, nonatomic) NSString *path; 14 | @property (readwrite, nonatomic) uint64_t size; 15 | @end 16 | 17 | @implementation STFileEntry 18 | 19 | @end 20 | -------------------------------------------------------------------------------- /TorrentKit/Core/STMagnetURI.h: -------------------------------------------------------------------------------- 1 | // 2 | // STMagnetURI.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/29/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #import "STDownloadable.h" 12 | 13 | NS_ASSUME_NONNULL_BEGIN 14 | 15 | NS_SWIFT_NAME(MagnetURI) 16 | @interface STMagnetURI : NSObject 17 | @property (readonly, strong, nonatomic) NSURL *magnetURI; 18 | 19 | - (instancetype)initWithMagnetURI:(NSURL *)magnetURI; 20 | 21 | #if DEBUG 22 | + (STMagnetURI *)test_1; 23 | #endif 24 | 25 | @end 26 | 27 | NS_ASSUME_NONNULL_END 28 | -------------------------------------------------------------------------------- /TorrentKit/Core/STMagnetURI.mm: -------------------------------------------------------------------------------- 1 | // 2 | // STMagnetURI.m 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/29/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import "STMagnetURI.h" 10 | 11 | #import "libtorrent/torrent_info.hpp" 12 | #import "libtorrent/add_torrent_params.hpp" 13 | #import "libtorrent/magnet_uri.hpp" 14 | #import "libtorrent/string_view.hpp" 15 | 16 | #if DEBUG 17 | #import "STTorrentFile.h" 18 | #endif 19 | 20 | @interface STMagnetURI () 21 | @property (readwrite, strong, nonatomic) NSURL *magnetURI; 22 | @end 23 | 24 | @implementation STMagnetURI 25 | 26 | - (instancetype)initWithMagnetURI:(NSURL *)magnetURI { 27 | self = [self init]; 28 | if (self) { 29 | _magnetURI = magnetURI; 30 | } 31 | return self; 32 | } 33 | 34 | #pragma mark - STDownloadable 35 | 36 | - (void)configureAddTorrentParams:(void *)params { 37 | lt::add_torrent_params *_params = (lt::add_torrent_params *)params; 38 | _params->flags |= libtorrent::torrent_flags::sequential_download; 39 | lt::error_code ec; 40 | lt::string_view uri = lt::string_view([self.magnetURI.absoluteString UTF8String]); 41 | lt::parse_magnet_uri(uri, (*_params), ec); 42 | if (ec.failed()) { 43 | NSLog(@"%s, error_code: %s", __FUNCTION__, ec.message().c_str()); 44 | } 45 | } 46 | 47 | #pragma mark - Test magnet links 48 | 49 | #if DEBUG 50 | + (STMagnetURI *)testMagnetAtIndex:(NSUInteger)index { 51 | NSArray *torrents = [STTorrentFile torrentsFromPlist]; 52 | NSArray *torrent = torrents[index]; 53 | 54 | NSString *magnetLink = torrent[1]; 55 | NSURL *magentURI = [NSURL URLWithString:magnetLink]; 56 | return [[STMagnetURI alloc] initWithMagnetURI:magentURI]; 57 | 58 | } 59 | 60 | + (STMagnetURI *)test_1 { 61 | return [self testMagnetAtIndex: 2]; 62 | } 63 | #endif 64 | 65 | @end 66 | -------------------------------------------------------------------------------- /TorrentKit/Core/STTorrent.h: -------------------------------------------------------------------------------- 1 | // 2 | // STTorrent.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/25/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #import "STTorrentState.h" 12 | 13 | NS_ASSUME_NONNULL_BEGIN 14 | 15 | @class STFileEntry; 16 | 17 | NS_SWIFT_NAME(Torrent) 18 | @interface STTorrent : NSObject 19 | @property (readonly, strong, nonatomic) NSData *infoHash; 20 | @property (readonly, strong, nonatomic) NSString *name; 21 | @property (readonly, nonatomic) STTorrentState state; 22 | @property (readonly, nonatomic) double progress; 23 | @property (readonly, nonatomic) NSUInteger numberOfPeers; 24 | @property (readonly, nonatomic) NSUInteger numberOfSeeds; 25 | @property (readonly, nonatomic) NSUInteger downloadRate; 26 | @property (readonly, nonatomic) NSUInteger uploadRate; 27 | @property (readonly, nonatomic) BOOL hasMetadata; 28 | 29 | #ifdef DEBUG 30 | + (instancetype)randomStubTorrent; 31 | #endif 32 | 33 | @end 34 | NS_ASSUME_NONNULL_END 35 | -------------------------------------------------------------------------------- /TorrentKit/Core/STTorrent.m: -------------------------------------------------------------------------------- 1 | // 2 | // STTorrent.m 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/25/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import "STTorrent.h" 10 | 11 | #import "STFileEntry.h" 12 | #import "STTorrentManager.h" 13 | 14 | @interface STTorrent () 15 | @property (readwrite, strong, nonatomic) NSData *infoHash; 16 | @property (readwrite, strong, nonatomic) NSString *name; 17 | @property (readwrite, nonatomic) STTorrentState state; 18 | @property (readwrite, nonatomic) double progress; 19 | @property (readwrite, nonatomic) NSUInteger numberOfPeers; 20 | @property (readwrite, nonatomic) NSUInteger numberOfSeeds; 21 | @property (readwrite, nonatomic) NSUInteger downloadRate; 22 | @property (readwrite, nonatomic) NSUInteger uploadRate; 23 | @property (readwrite, nonatomic) BOOL hasMetadata; 24 | @end 25 | 26 | @implementation STTorrent 27 | 28 | #ifdef DEBUG 29 | static NSUInteger stubIdx = 0; 30 | 31 | + (instancetype)randomStubTorrent { 32 | STTorrent *torrent = [[STTorrent alloc] init]; 33 | torrent.infoHash = [[[NSUUID UUID] UUIDString] dataUsingEncoding:NSUTF8StringEncoding]; 34 | torrent.name = [NSString stringWithFormat:@"Stub torrent (%2lu)", stubIdx]; 35 | torrent.state = stubIdx % ((NSUInteger)STTorrentStateCheckingResumeData); 36 | torrent.progress = (double)arc4random() / UINT32_MAX; 37 | torrent.numberOfPeers = arc4random_uniform(100); 38 | torrent.numberOfSeeds = arc4random_uniform(100); 39 | torrent.downloadRate = arc4random_uniform(1024 * 1024 * 1024); 40 | torrent.uploadRate = arc4random_uniform(1024 * 1024 * 1024); 41 | torrent.hasMetadata = torrent.state != STTorrentStateDownloadingMetadata; 42 | stubIdx += 1; 43 | return torrent; 44 | } 45 | #endif 46 | 47 | @end 48 | -------------------------------------------------------------------------------- /TorrentKit/Core/STTorrentFile.h: -------------------------------------------------------------------------------- 1 | // 2 | // STTorrentFile.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/29/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #import "STDownloadable.h" 12 | 13 | NS_ASSUME_NONNULL_BEGIN 14 | 15 | NS_SWIFT_NAME(TorrentFile) 16 | @interface STTorrentFile : NSObject 17 | @property (readonly, strong, nonatomic) NSData *fileData; 18 | 19 | - (instancetype)initWithFileAtURL:(NSURL *)fileURL; 20 | 21 | #if DEBUG 22 | + (NSArray *)torrentsFromPlist; 23 | + (STTorrentFile *)test_1; 24 | + (STTorrentFile *)test_2; 25 | #endif 26 | 27 | @end 28 | 29 | NS_ASSUME_NONNULL_END 30 | -------------------------------------------------------------------------------- /TorrentKit/Core/STTorrentFile.mm: -------------------------------------------------------------------------------- 1 | // 2 | // STTorrentFile.m 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/29/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import "STTorrentFile.h" 10 | 11 | #import "libtorrent/torrent_info.hpp" 12 | #import "libtorrent/add_torrent_params.hpp" 13 | 14 | @interface STTorrentFile () 15 | @property (readwrite, strong, nonatomic) NSData *fileData; 16 | @end 17 | 18 | @implementation STTorrentFile 19 | 20 | - (instancetype)initWithFileAtURL:(NSURL *)fileURL { 21 | self = [self init]; 22 | if (self) { 23 | _fileData = [NSData dataWithContentsOfURL:fileURL]; 24 | } 25 | return self; 26 | } 27 | 28 | #pragma mark - STDownloadable 29 | 30 | - (lt::torrent_info)torrent_info { 31 | uint8_t *buffer = (uint8_t *)[self.fileData bytes]; 32 | size_t size = [self.fileData length]; 33 | return lt::torrent_info((char *)buffer, (int)size);; 34 | } 35 | 36 | - (void)configureAddTorrentParams:(void *)params { 37 | lt::add_torrent_params *_params = (lt::add_torrent_params *)params; 38 | lt::torrent_info ti = [self torrent_info]; 39 | _params->ti = std::make_shared(ti); 40 | _params->flags |= libtorrent::torrent_flags::sequential_download; 41 | } 42 | 43 | #pragma mark - Test torrents 44 | 45 | #if DEBUG 46 | + (NSArray *)torrentsFromPlist { 47 | NSBundle *bundle = [NSBundle bundleForClass:self]; 48 | NSURL *plsitURL = [bundle URLForResource:@"Torrents.plist" withExtension:nil]; 49 | NSData *plistData = [NSData dataWithContentsOfURL:plsitURL options:0 error:nil]; 50 | NSDictionary *dict = [NSPropertyListSerialization propertyListWithData:plistData options:0 format:nil error:nil]; 51 | return dict[@"torrents"]; 52 | } 53 | 54 | + (STTorrentFile *)testFileAtIndex:(NSUInteger)index { 55 | NSArray *torrents = [self torrentsFromPlist]; 56 | NSArray *torrent = torrents[index]; 57 | 58 | NSString *fileName = [torrent[0] stringByAppendingPathExtension:@"torrent"]; 59 | NSData *fileData = [[NSData alloc] initWithBase64EncodedString:torrent[2] options:0]; 60 | 61 | NSString *cacheDir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; 62 | NSString *filePath = [cacheDir stringByAppendingPathComponent:fileName]; 63 | [fileData writeToFile:filePath atomically:YES]; 64 | 65 | NSURL *fileURL = [NSURL fileURLWithPath:filePath]; 66 | return [[STTorrentFile alloc] initWithFileAtURL:fileURL]; 67 | } 68 | 69 | + (STTorrentFile *)test_1 { 70 | return [self testFileAtIndex:0]; 71 | } 72 | 73 | + (STTorrentFile *)test_2 { 74 | return [self testFileAtIndex:1]; 75 | } 76 | #endif 77 | 78 | @end 79 | -------------------------------------------------------------------------------- /TorrentKit/Core/STTorrentManager.h: -------------------------------------------------------------------------------- 1 | // 2 | // STTorrentManager.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/24/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #import "STDownloadable.h" 12 | #import "STTorrentManagerProtocol.h" 13 | 14 | NS_ASSUME_NONNULL_BEGIN 15 | 16 | typedef NS_ENUM(NSUInteger, STErrorCode) { 17 | STErrorCodeBadFile, 18 | STErrorCodeUndefined 19 | } NS_SWIFT_NAME(STErrorCode); 20 | 21 | @class STTorrentManager, STTorrent, STFileEntry; 22 | 23 | NS_SWIFT_NAME(TorrentManagerDelegate) 24 | @protocol STTorrentManagerDelegate 25 | 26 | - (void)torrentManager:(STTorrentManager *)manager didAddTorrent:(STTorrent *)torrent; 27 | 28 | - (void)torrentManager:(STTorrentManager *)manager didRemoveTorrentWithHash:(NSData *)hashData; 29 | 30 | - (void)torrentManager:(STTorrentManager *)manager didReceiveUpdateForTorrent:(STTorrent *)torrent; 31 | 32 | - (void)torrentManager:(STTorrentManager *)manager didErrorOccur:(NSError *)error; 33 | 34 | @end 35 | 36 | NS_SWIFT_NAME(TorrentManager) 37 | @interface STTorrentManager : NSObject 38 | @property (readonly, nonatomic, getter=isSessionActive) BOOL sessionActive; 39 | 40 | + (instancetype)sharedInstance 41 | NS_SWIFT_NAME(shared()); 42 | 43 | - (instancetype)init 44 | NS_UNAVAILABLE; 45 | 46 | - (void)addDelegate:(id)delegate 47 | NS_SWIFT_NAME(addDelegate(_:)); 48 | 49 | - (void)removeDelegate:(id)delegate 50 | NS_SWIFT_NAME(removeDelegate(_:)); 51 | 52 | - (void)restoreSession; 53 | 54 | - (BOOL)addTorrent:(id)torrent 55 | NS_SWIFT_NAME(add(_:)); 56 | 57 | - (BOOL)removeTorrentWithInfoHash:(NSData *)infoHash deleteFiles:(BOOL)deleteFiles; 58 | 59 | - (BOOL)removeAllTorrentsWithFiles:(BOOL)deleteFiles; 60 | 61 | - (NSArray *)torrents; 62 | 63 | - (void)openURL:(NSURL *)URL; 64 | 65 | - (NSArray *)filesForTorrentWithHash:(NSData *)infoHash; 66 | 67 | - (NSURL *)downloadsDirectoryURL; 68 | 69 | @end 70 | 71 | NS_ASSUME_NONNULL_END 72 | -------------------------------------------------------------------------------- /TorrentKit/Core/STTorrentManager.mm: -------------------------------------------------------------------------------- 1 | // 2 | // STTorrentManager.m 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/24/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import "STTorrentManager.h" 10 | 11 | #import "STTorrent.h" 12 | #import "STTorrentFile.h" 13 | #import "STMagnetURI.h" 14 | #import "STFileEntry.h" 15 | 16 | #import "NSData+Hex.h" 17 | 18 | //libtorrent 19 | #import "libtorrent/session.hpp" 20 | #import "libtorrent/alert.hpp" 21 | #import "libtorrent/alert_types.hpp" 22 | 23 | #import "libtorrent/torrent_handle.hpp" 24 | #import "libtorrent/torrent_info.hpp" 25 | #import "libtorrent/create_torrent.hpp" 26 | #import "libtorrent/magnet_uri.hpp" 27 | 28 | #import "libtorrent/bencode.hpp" 29 | #import "libtorrent/bdecode.hpp" 30 | 31 | @interface STTorrent () 32 | @property (readwrite, strong, nonatomic) NSData *infoHash; 33 | @property (readwrite, nonatomic) STTorrentState state; 34 | @property (readwrite, strong, nonatomic) NSString *name; 35 | @property (readwrite, nonatomic) double progress; 36 | @property (readwrite, nonatomic) NSUInteger numberOfPeers; 37 | @property (readwrite, nonatomic) NSUInteger numberOfSeeds; 38 | @property (readwrite, nonatomic) NSUInteger downloadRate; 39 | @property (readwrite, nonatomic) NSUInteger uploadRate; 40 | @property (readwrite, nonatomic) BOOL hasMetadata; 41 | @end 42 | 43 | @interface STFileEntry () 44 | @property (readwrite, strong, nonatomic) NSString *name; 45 | @property (readwrite, strong, nonatomic) NSString *path; 46 | @property (readwrite, nonatomic) uint64_t size; 47 | @end 48 | 49 | #pragma mark - 50 | 51 | static char const * const STEventsQueueIdentifier = "org.kostyshyn.SwiftyTorrent.STTorrentManager.events.queue"; 52 | static char const * const STFileEntriesQueueIdentifier = "org.kostyshyn.SwiftyTorrent.STTorrentManager.files.queue"; 53 | static NSErrorDomain STErrorDomain = @"org.kostyshyn.SwiftyTorrent.STTorrentManager.error"; 54 | 55 | @interface STTorrentManager () { 56 | lt::session *_session; 57 | } 58 | @property (strong, nonatomic) NSThread *eventsThread; 59 | @property (strong, nonatomic) dispatch_queue_t filesQueue; 60 | @property (strong, nonatomic) NSHashTable *delegates; 61 | 62 | - (instancetype)init; 63 | 64 | @end 65 | 66 | @implementation STTorrentManager 67 | 68 | + (instancetype)sharedInstance { 69 | static dispatch_once_t onceToken; 70 | static STTorrentManager *manager; 71 | dispatch_once(&onceToken, ^{ 72 | manager = [[STTorrentManager alloc] init]; 73 | }); 74 | return manager; 75 | } 76 | 77 | - (instancetype)init { 78 | self = [super init]; 79 | if (self) { 80 | _session = new lt::session(); 81 | _session->set_alert_mask(lt::alert::all_categories); 82 | _filesQueue = dispatch_queue_create(STFileEntriesQueueIdentifier, DISPATCH_QUEUE_SERIAL); 83 | _delegates = [NSHashTable weakObjectsHashTable]; 84 | 85 | // restore session 86 | [self restoreSession]; 87 | 88 | // start alerts loop 89 | _eventsThread = [[NSThread alloc] initWithTarget:self selector:@selector(alertsLoop) object:nil]; 90 | [_eventsThread setName:[NSString stringWithUTF8String:STEventsQueueIdentifier]]; 91 | [_eventsThread setQualityOfService:NSQualityOfServiceDefault]; 92 | [_eventsThread start]; 93 | } 94 | return self; 95 | } 96 | 97 | - (void)dealloc { 98 | delete _session; 99 | } 100 | 101 | #pragma mark - 102 | 103 | - (BOOL)isSessionActive { 104 | return YES; 105 | } 106 | 107 | #pragma mark - 108 | 109 | - (void)notifyDelegatesAboutError:(NSError *)error { 110 | for (iddelegate in self.delegates) { 111 | [delegate torrentManager:self didErrorOccur:error]; 112 | } 113 | } 114 | 115 | - (NSError *)errorWithCode:(STErrorCode)code message:(NSString *)message { 116 | return [NSError errorWithDomain:STErrorDomain 117 | code:code 118 | userInfo:@{NSLocalizedDescriptionKey: message}]; 119 | } 120 | 121 | #pragma mark - Alerts Loop 122 | 123 | #define ALERTS_LOOP_WAIT_MILLIS 500 124 | 125 | - (void)alertsLoop { 126 | auto max_wait = lt::milliseconds(ALERTS_LOOP_WAIT_MILLIS); 127 | while (YES) { 128 | auto alert_ptr = _session->wait_for_alert(max_wait); 129 | std::vector alerts_queue; 130 | if (alert_ptr != nullptr) { 131 | _session->pop_alerts(&alerts_queue); 132 | } else { 133 | continue; 134 | } 135 | 136 | for (auto it = alerts_queue.begin(); it != alerts_queue.end(); ++it) { 137 | auto alert = (*it); 138 | // NSLog(@"type:%d msg:%s", alert->type(), alert->message().c_str()); 139 | switch (alert->type()) { 140 | case lt::metadata_received_alert::alert_type: { 141 | } break; 142 | 143 | case lt::metadata_failed_alert::alert_type: { 144 | [self metadataReceivedAlert:(lt::torrent_alert *)alert]; 145 | } break; 146 | 147 | case lt::block_finished_alert::alert_type: { 148 | } break; 149 | 150 | case lt::add_torrent_alert::alert_type: { 151 | [self torrentAddedAlert:(lt::torrent_alert *)alert]; 152 | } break; 153 | 154 | case lt::torrent_removed_alert::alert_type: { 155 | [self torrentRemovedAlert:(lt::torrent_alert *)alert]; 156 | } break; 157 | 158 | case lt::torrent_finished_alert::alert_type: { 159 | } break; 160 | 161 | case lt::torrent_paused_alert::alert_type: { 162 | } break; 163 | 164 | case lt::torrent_resumed_alert::alert_type: { 165 | } break; 166 | 167 | case lt::torrent_error_alert::alert_type: { 168 | } break; 169 | 170 | default: break; 171 | } 172 | 173 | if (dynamic_cast(alert) != nullptr) { 174 | auto th = ((lt::torrent_alert *)alert)->handle; 175 | if (!th.is_valid()) { break; } 176 | [self notifyDelegatesWithUpdate:th]; 177 | } 178 | } 179 | 180 | alerts_queue.clear(); 181 | } 182 | } 183 | 184 | - (void)notifyDelegatesWithAdd:(lt::torrent_handle)th { 185 | STTorrent *torrent = [self torrentFromHandle:th]; 186 | for (iddelegate in self.delegates) { 187 | [delegate torrentManager:self didAddTorrent:torrent]; 188 | } 189 | } 190 | 191 | - (void)notifyDelegatesWithRemove:(lt::torrent_handle)th { 192 | NSData *hashData = [self hashDataFromInfoHash:th.info_hash()]; 193 | for (iddelegate in self.delegates) { 194 | [delegate torrentManager:self didRemoveTorrentWithHash:hashData]; 195 | } 196 | } 197 | 198 | - (void)notifyDelegatesWithUpdate:(lt::torrent_handle)th { 199 | STTorrent *torrent = [self torrentFromHandle:th]; 200 | for (iddelegate in self.delegates) { 201 | [delegate torrentManager:self didReceiveUpdateForTorrent:torrent]; 202 | } 203 | } 204 | 205 | - (void)metadataReceivedAlert:(lt::torrent_alert *)alert { 206 | auto th = alert->handle; 207 | } 208 | 209 | - (void)torrentAddedAlert:(lt::torrent_alert *)alert { 210 | auto th = alert->handle; 211 | [self notifyDelegatesWithAdd:th]; 212 | if (!th.is_valid()) { 213 | NSLog(@"%s: torrent_handle is invalid!", __FUNCTION__); 214 | return; 215 | } 216 | 217 | bool has_metadata = th.status().has_metadata; 218 | auto torrent_info = th.torrent_file(); 219 | auto margnet_uri = lt::make_magnet_uri(th); 220 | dispatch_async(self.filesQueue, ^{ 221 | if (has_metadata) { 222 | [self saveTorrentFileWithInfo:torrent_info]; 223 | } else { 224 | [self saveMagnetURIWithContent:margnet_uri]; 225 | } 226 | }); 227 | } 228 | 229 | - (void)torrentRemovedAlert:(lt::torrent_alert *)alert { 230 | auto th = alert->handle; 231 | [self notifyDelegatesWithRemove:th]; 232 | if (!th.is_valid()) { 233 | NSLog(@"%s: torrent_handle is invalid!", __FUNCTION__); 234 | return; 235 | } 236 | 237 | auto torrent_info = th.torrent_file(); 238 | auto info_hash = th.info_hash(); 239 | dispatch_async(self.filesQueue, ^{ 240 | [self removeTorrentFileWithInfo:torrent_info]; 241 | [self removeMagnetURIWithHash:info_hash]; 242 | }); 243 | } 244 | 245 | #pragma mark - 246 | 247 | - (NSURL *)downloadsDirectoryURL { 248 | return [NSURL fileURLWithPath:[self downloadsDirPath] isDirectory:YES]; 249 | } 250 | 251 | - (NSString *)storageDirPath { 252 | #if TARGET_OS_IOS 253 | return [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; 254 | #elif TARGET_OS_TV 255 | return [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; 256 | #endif 257 | } 258 | 259 | - (NSString *)downloadsDirPath { 260 | NSString *downloadsDirPath = [[self storageDirPath] stringByAppendingPathComponent:@"Downloads"]; 261 | return downloadsDirPath; 262 | } 263 | 264 | - (NSString *)torrentsDirPath { 265 | NSString *torrentsDirPath = [[self storageDirPath] stringByAppendingPathComponent:@"torrents"]; 266 | BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:torrentsDirPath]; 267 | if (!fileExists) { 268 | NSError *error; 269 | [[NSFileManager defaultManager] createDirectoryAtPath:torrentsDirPath 270 | withIntermediateDirectories:YES 271 | attributes:nil 272 | error:&error]; 273 | if (error) { NSLog(@"%@", error); } 274 | } 275 | return torrentsDirPath; 276 | } 277 | 278 | - (NSString *)magnetURIsFilePath { 279 | return [[self torrentsDirPath] stringByAppendingPathComponent:@"magnet_links"]; 280 | } 281 | 282 | #pragma mark - 283 | 284 | - (void)saveTorrentFileWithInfo:(std::shared_ptr)ti { 285 | if (ti == nullptr) { return; } 286 | 287 | lt::create_torrent new_torrent(*ti); 288 | std::vector out_file; 289 | lt::bencode(std::back_inserter(out_file), new_torrent.generate()); 290 | 291 | NSString *fileName = [NSString stringWithFormat:@"%s.torrent", (*ti).name().c_str()]; 292 | NSString *filePath = [[self torrentsDirPath] stringByAppendingPathComponent:fileName]; 293 | NSData *data = [NSData dataWithBytes:out_file.data() length:out_file.size()]; 294 | BOOL success = [data writeToFile:filePath atomically:YES]; 295 | if (!success) { NSLog(@"Can't save .torrent file"); } 296 | } 297 | 298 | - (void)saveMagnetURIWithContent:(std::string)uri { 299 | if (uri.length() < 1) { return; } 300 | 301 | NSString *magnetURI = [NSString stringWithUTF8String:uri.c_str()]; 302 | [self appendMagnetURIToFileStore:magnetURI]; 303 | } 304 | 305 | - (void)appendMagnetURIToFileStore:(NSString *)magnetURI { 306 | NSString *magnetURIsFilePath = [self magnetURIsFilePath]; 307 | // read from existing file 308 | NSError *error; 309 | NSString *fileContent = [NSString stringWithContentsOfFile:magnetURIsFilePath 310 | encoding:NSUTF8StringEncoding 311 | error:&error]; 312 | if (error) { NSLog(@"%@", error); } 313 | 314 | NSMutableArray *magnetURIs = [[fileContent componentsSeparatedByString:@"\n"] mutableCopy]; 315 | if (magnetURIs == nil) { 316 | magnetURIs = [[NSMutableArray alloc] init]; 317 | } 318 | // remove all existing copies 319 | NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF CONTAINS[cd] %@)", magnetURI]; 320 | [magnetURIs filterUsingPredicate:predicate]; 321 | // add new uri 322 | [magnetURIs addObject:magnetURI]; 323 | 324 | // save to file 325 | fileContent = [magnetURIs componentsJoinedByString:@"\n"]; 326 | [fileContent writeToFile:magnetURIsFilePath 327 | atomically:YES 328 | encoding:NSUTF8StringEncoding 329 | error:&error]; 330 | if (error) { NSLog(@"%@", error); } 331 | } 332 | 333 | - (void)removeTorrentFileWithInfo:(std::shared_ptr)ti { 334 | if (ti == nullptr) { return; } 335 | 336 | NSString *fileName = [NSString stringWithFormat:@"%s.torrent", (*ti).name().c_str()]; 337 | NSString *filePath = [[self torrentsDirPath] stringByAppendingPathComponent:fileName]; 338 | 339 | NSError *error; 340 | BOOL success = [[NSFileManager defaultManager] removeItemAtPath:filePath error:&error]; 341 | if (error) { NSLog(@"success: %d, %@", success, error); } 342 | } 343 | 344 | - (NSData *)hashDataFromInfoHash:(lt::sha1_hash)info_hash { 345 | return [NSData dataWithBytes:info_hash.data() 346 | length:info_hash.size()]; 347 | } 348 | 349 | - (void)removeMagnetURIWithHash:(lt::sha1_hash)info_hash { 350 | NSData *hashData = [self hashDataFromInfoHash:info_hash]; 351 | [self removeFromFileStoreMagnetURIWithHash:hashData.hexString]; 352 | } 353 | 354 | - (void)removeFromFileStoreMagnetURIWithHash:(NSString *)hashString { 355 | NSString *magnetURIsFilePath = [self magnetURIsFilePath]; 356 | // read from existing file 357 | NSError *error; 358 | NSString *fileContent = [NSString stringWithContentsOfFile:magnetURIsFilePath 359 | encoding:NSUTF8StringEncoding 360 | error:&error]; 361 | if (error) { NSLog(@"%@", error); } 362 | 363 | NSMutableArray *magnetURIs = [[fileContent componentsSeparatedByString:@"\n"] mutableCopy]; 364 | // remove all existing copies 365 | NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF CONTAINS[cd] %@)", hashString]; 366 | [magnetURIs filterUsingPredicate:predicate]; 367 | 368 | // save to file 369 | fileContent = [magnetURIs componentsJoinedByString:@"\n"]; 370 | [fileContent writeToFile:magnetURIsFilePath 371 | atomically:YES 372 | encoding:NSUTF8StringEncoding 373 | error:&error]; 374 | if (error) { NSLog(@"%@", error); } 375 | } 376 | 377 | #pragma mark - 378 | 379 | - (void)addDelegate:(id)delegate { 380 | [self.delegates addObject:delegate]; 381 | } 382 | 383 | - (void)removeDelegate:(id)delegate { 384 | [self.delegates removeObject:delegate]; 385 | } 386 | 387 | #pragma mark - Public Methods 388 | 389 | - (void)restoreSession { 390 | NSString *torrentsDirPath = [self torrentsDirPath]; 391 | NSString *marngetURIsFilePath = [self magnetURIsFilePath]; 392 | 393 | NSError *error; 394 | // load .torrents files 395 | NSArray *torrentsDirFiles = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:torrentsDirPath error:&error]; 396 | NSLog(@"%@", torrentsDirPath); 397 | if (error) { NSLog(@"%@", error); } 398 | 399 | torrentsDirFiles = [torrentsDirFiles filteredArrayUsingPredicate: 400 | [NSPredicate predicateWithFormat:@"self ENDSWITH %@", @".torrent"]]; 401 | for (NSString *fileName in torrentsDirFiles) { 402 | NSString *filePath = [torrentsDirPath stringByAppendingPathComponent:fileName]; 403 | NSURL *fileURL = [NSURL fileURLWithPath:filePath]; 404 | STTorrentFile *torrent = [[STTorrentFile alloc] initWithFileAtURL:fileURL]; 405 | [self addTorrent:torrent]; 406 | } 407 | 408 | // load magnet links 409 | NSString *magnetURIsContent = [NSString stringWithContentsOfFile:marngetURIsFilePath 410 | encoding:NSUTF8StringEncoding 411 | error:&error]; 412 | if (error) { NSLog(@"%@", error); } 413 | 414 | NSArray *magnetURIs = [magnetURIsContent componentsSeparatedByString:@"\n"]; 415 | for (NSString *magnetURIString in magnetURIs) { 416 | if (magnetURIString.length == 0) { 417 | continue; 418 | } 419 | NSURL *magnetURI = [NSURL URLWithString:magnetURIString]; 420 | STMagnetURI *torrent = [[STMagnetURI alloc] initWithMagnetURI:magnetURI]; 421 | [self addTorrent:torrent]; 422 | } 423 | } 424 | 425 | - (BOOL)addTorrent:(id)torrent { 426 | lt::add_torrent_params params; 427 | params.save_path = [[self downloadsDirPath] UTF8String]; 428 | try { 429 | [torrent configureAddTorrentParams:(void *)¶ms]; 430 | } catch (...) { 431 | NSError *error = [self errorWithCode:STErrorCodeBadFile message:@"Failed to add torrent"]; 432 | NSLog(@"%@", error); 433 | [self notifyDelegatesAboutError:error]; 434 | return NO; 435 | } 436 | auto th = _session->add_torrent(params); 437 | return YES; 438 | } 439 | 440 | - (void)removeStoredTorrentOrMagnet:(lt::torrent_handle)th { 441 | // Remove stored torrent 442 | auto ti = th.torrent_file(); 443 | [self removeTorrentFileWithInfo:ti]; 444 | auto ih = th.info_hash(); 445 | [self removeMagnetURIWithHash:ih]; 446 | } 447 | 448 | - (BOOL)removeTorrentWithInfoHash:(NSData *)infoHash deleteFiles:(BOOL)deleteFiles { 449 | lt::sha1_hash hash((const char *)infoHash.bytes); 450 | auto th = _session->find_torrent(hash); 451 | if (!th.is_valid()) { return NO; } 452 | 453 | [self removeStoredTorrentOrMagnet:th]; 454 | 455 | // Remove torrrent from session 456 | if (deleteFiles) { 457 | _session->remove_torrent(th, lt::session::delete_files); 458 | } else { 459 | _session->remove_torrent(th); 460 | } 461 | return YES; 462 | } 463 | 464 | - (BOOL)removeAllTorrentsWithFiles:(BOOL)deleteFiles { 465 | auto handles = _session->get_torrents(); 466 | for (auto it = handles.begin(); it != handles.end(); ++it) { 467 | auto th = (*it); 468 | 469 | [self removeStoredTorrentOrMagnet:th]; 470 | 471 | // Remove torrrent from session 472 | if (deleteFiles) { 473 | _session->remove_torrent(th, lt::session::delete_files); 474 | } else { 475 | _session->remove_torrent(th); 476 | } 477 | } 478 | return YES; 479 | } 480 | 481 | - (void)openURL:(NSURL *)URL { 482 | if (URL.isFileURL) { 483 | BOOL success = [URL startAccessingSecurityScopedResource]; 484 | if (success) { 485 | STTorrentFile *torrent = [[STTorrentFile alloc] initWithFileAtURL:URL]; 486 | [self addTorrent:torrent]; 487 | [URL stopAccessingSecurityScopedResource]; 488 | } 489 | } else { 490 | STMagnetURI *torrent = [[STMagnetURI alloc] initWithMagnetURI:URL]; 491 | [self addTorrent:torrent]; 492 | } 493 | } 494 | 495 | #pragma mark - 496 | 497 | - (STTorrentState)stateFromTorrentSatus:(lt::torrent_status)status { 498 | switch (status.state) { 499 | case lt::torrent_status::state_t::checking_files: return STTorrentStateCheckingFiles; 500 | case lt::torrent_status::state_t::downloading_metadata: return STTorrentStateDownloadingMetadata; 501 | case lt::torrent_status::state_t::downloading: return STTorrentStateDownloading; 502 | case lt::torrent_status::state_t::finished: return STTorrentStateFinished; 503 | case lt::torrent_status::state_t::seeding: return STTorrentStateSeeding; 504 | case lt::torrent_status::state_t::allocating: return STTorrentStateAllocating; 505 | case lt::torrent_status::state_t::checking_resume_data: return STTorrentStateCheckingResumeData; 506 | } 507 | } 508 | 509 | - (STTorrent *)torrentFromHandle:(lt::torrent_handle)th { 510 | STTorrent *torrent = [[STTorrent alloc] init]; 511 | auto ih = th.info_hash(); 512 | torrent.infoHash = [NSData dataWithBytes:ih.data() length:ih.size()]; 513 | auto ts = th.status(); 514 | torrent.state = [self stateFromTorrentSatus:ts]; 515 | torrent.name = [NSString stringWithUTF8String:ts.name.c_str()]; 516 | torrent.progress = ts.progress; 517 | torrent.numberOfPeers = ts.num_peers; 518 | torrent.numberOfSeeds = ts.num_seeds; 519 | torrent.uploadRate = ts.upload_payload_rate; 520 | torrent.downloadRate = ts.download_payload_rate; 521 | torrent.hasMetadata = ts.has_metadata; 522 | return torrent; 523 | } 524 | 525 | - (NSArray *)torrents { 526 | auto handles = _session->get_torrents(); 527 | NSMutableArray *torrents = [[NSMutableArray alloc] init]; 528 | for (auto it = handles.begin(); it != handles.end(); ++it) { 529 | auto th = (*it); 530 | [torrents addObject:[self torrentFromHandle:th]]; 531 | } 532 | return [torrents copy]; 533 | } 534 | 535 | - (NSArray *)filesForTorrentWithHash:(NSData *)infoHash { 536 | lt::sha1_hash ih = lt::sha1_hash((const char *)infoHash.bytes); 537 | auto th = _session->find_torrent(ih); 538 | if (!th.is_valid()) { 539 | NSLog(@"No a valid torrent with hash: %@", infoHash.hexString); 540 | return nil; 541 | } 542 | NSMutableArray *results = [[NSMutableArray alloc] init]; 543 | auto ti = th.torrent_file(); 544 | if (ti == nullptr) { 545 | NSLog(@"No metadata for torrent with name: %s", th.status().name.c_str()); 546 | return nil; 547 | } 548 | auto files = ti.get()->files(); 549 | for (int i=0; i)delegate 19 | NS_SWIFT_NAME(addDelegate(_:)); 20 | 21 | - (void)removeDelegate:(id)delegate 22 | NS_SWIFT_NAME(removeDelegate(_:)); 23 | 24 | - (void)restoreSession; 25 | 26 | - (BOOL)addTorrent:(id)torrent 27 | NS_SWIFT_NAME(add(_:)); 28 | 29 | - (BOOL)removeTorrentWithInfoHash:(NSData *)infoHash deleteFiles:(BOOL)deleteFiles; 30 | 31 | - (BOOL)removeAllTorrentsWithFiles:(BOOL)deleteFiles; 32 | 33 | - (NSArray *)torrents; 34 | 35 | - (void)openURL:(NSURL *)URL; 36 | 37 | - (NSArray *)filesForTorrentWithHash:(NSData *)infoHash; 38 | 39 | - (NSURL *)downloadsDirectoryURL; 40 | 41 | @end 42 | 43 | NS_ASSUME_NONNULL_END 44 | -------------------------------------------------------------------------------- /TorrentKit/Core/STTorrentState.h: -------------------------------------------------------------------------------- 1 | // 2 | // STTorrentState.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/26/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | typedef NS_ENUM(NSUInteger, STTorrentState) { 12 | STTorrentStateCheckingFiles, 13 | STTorrentStateDownloadingMetadata, 14 | STTorrentStateDownloading, 15 | STTorrentStateFinished, 16 | STTorrentStateSeeding, 17 | STTorrentStateAllocating, 18 | STTorrentStateCheckingResumeData 19 | } NS_SWIFT_NAME(Torrent.State); 20 | -------------------------------------------------------------------------------- /TorrentKit/Core/TorrentState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentState.swift 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 6/26/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Torrent.State: CustomStringConvertible { 12 | 13 | public var description: String { 14 | switch self { 15 | case .checkingFiles: return "CheckingFiles" 16 | case .downloadingMetadata: return "DownloadingMetadata" 17 | case .downloading: return "Downloading" 18 | case .finished: return "Finished" 19 | case .seeding: return "Seeding" 20 | case .allocating: return "Allocating" 21 | case .checkingResumeData: return "CheckingResumeData" 22 | @unknown default: return "Unknown" 23 | } 24 | } 25 | 26 | public var symbol: String { 27 | switch self { 28 | case .downloading: return "↓" 29 | case .seeding: return "↑" 30 | default: return "*" 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /TorrentKit/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /TorrentKit/TorrentKit.h: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentKit.h 3 | // TorrentKit 4 | // 5 | // Created by Danylo Kostyshyn on 29.06.2020. 6 | // Copyright © 2020 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for TorrentKit. 12 | FOUNDATION_EXPORT double TorrentKitVersionNumber; 13 | 14 | //! Project version string for TorrentKit. 15 | FOUNDATION_EXPORT const unsigned char TorrentKitVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | #import 20 | #import 21 | #import 22 | #import 23 | #import 24 | -------------------------------------------------------------------------------- /TorrentKit/Utils/NSData+Hex.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSData+Hex.h 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/14/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | @interface NSData (Hex) 14 | 15 | - (NSString *)hexString 16 | NS_SWIFT_NAME(hex()); 17 | 18 | @end 19 | 20 | NS_ASSUME_NONNULL_END 21 | -------------------------------------------------------------------------------- /TorrentKit/Utils/NSData+Hex.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSData+Hex.m 3 | // SwiftyTorrent 4 | // 5 | // Created by Danylo Kostyshyn on 7/14/19. 6 | // Copyright © 2019 Danylo Kostyshyn. All rights reserved. 7 | // 8 | 9 | #import "NSData+Hex.h" 10 | 11 | @implementation NSData (Hex) 12 | 13 | - (NSString *)hexString { 14 | const uint8_t *buffer = (const uint8_t *)self.bytes; 15 | NSMutableString *hexString = [NSMutableString stringWithCapacity:(self.length * 2)]; 16 | for (int i=0; i> $xcconfig 16 | 17 | echo 'EXCLUDED_ARCHS__EFFECTIVE_PLATFORM_SUFFIX_simulator__NATIVE_ARCH_64_BIT_x86_64__XCODE_1300 = $(EXCLUDED_ARCHS__EFFECTIVE_PLATFORM_SUFFIX_simulator__NATIVE_ARCH_64_BIT_x86_64__XCODE_1300__BUILD_$(XCODE_PRODUCT_BUILD_VERSION))' >> $xcconfig 18 | echo 'EXCLUDED_ARCHS = $(inherited) $(EXCLUDED_ARCHS__EFFECTIVE_PLATFORM_SUFFIX_$(EFFECTIVE_PLATFORM_SUFFIX)__NATIVE_ARCH_64_BIT_$(NATIVE_ARCH_64_BIT)__XCODE_$(XCODE_VERSION_MAJOR))' >> $xcconfig 19 | 20 | export XCODE_XCCONFIG_FILE="$xcconfig" 21 | carthage "$@" --------------------------------------------------------------------------------