├── .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 | 
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 "$@"
--------------------------------------------------------------------------------