├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── automated-ios-build.yml ├── .gitignore ├── LICENSE ├── Localization └── Localizations │ └── Localizable.xcstrings ├── Package.swift ├── README.md ├── Sources └── qBitControl │ └── qBitControl.swift ├── Tests └── qBitControlTests │ └── qBitControlTests.swift ├── qBitControl.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── WorkspaceSettings.xcsettings ├── qBitControl.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── qBitControl ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── logo1.png │ ├── Contents.json │ └── logo.imageset │ │ ├── Contents.json │ │ ├── logo1 1.png │ │ ├── logo1 2.png │ │ └── logo1.png ├── Classes │ ├── AppInfo.swift │ ├── AuthClass.swift │ ├── FileNodeClass.swift │ ├── LocalNetworkPermissionClass.swift │ ├── ServersHelper.swift │ ├── qBitDataClass.swift │ ├── qBitRequestClass.swift │ └── qBittorrentClass.swift ├── Components │ ├── ChangeCategoryView.swift │ ├── ChangePathView.swift │ ├── ChangeTagsView.swift │ ├── CustomLabelView.swift │ ├── MenuControlsLabelView.swift │ ├── PeersRowView.swift │ ├── StatsChartView.swift │ ├── TorrentRowView.swift │ └── TrackerRow.swift ├── Info.plist ├── Models │ ├── AlertIdentifier.swift │ ├── Category.swift │ ├── File.swift │ ├── GlobalTransferInfo.swift │ ├── MainData.swift │ ├── PartialServerState.swift │ ├── Peer.swift │ ├── Peers.swift │ ├── RSSFeed.swift │ ├── RSSNode.swift │ ├── SearchCategory.swift │ ├── SearchPlugin.swift │ ├── SearchResponse.swift │ ├── SearchResult.swift │ ├── SearchSortOptions.swift │ ├── SearchStartResult.swift │ ├── Server.swift │ ├── ServerAction.swift │ ├── ServerState.swift │ ├── SheetIdentifier.swift │ ├── Torrent.swift │ ├── Tracker.swift │ ├── TransferInfo.swift │ ├── Version.swift │ └── qBitPreferences.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Toolbars │ └── TorrentListToolbar │ │ ├── TorrentListDefaultToolbar.swift │ │ ├── TorrentListSelectionToolbar.swift │ │ └── TorrentListToolbar.swift ├── TorrentView │ └── ListElementView.swift ├── ViewModels │ ├── MainViewModel.swift │ ├── RSSView │ │ ├── RSSNodeViewModel.swift │ │ └── RSSViewModel.swift │ ├── SearchView │ │ └── SearchViewModel.swift │ ├── ServersView │ │ └── ServerAddViewModel.swift │ └── TorrentView │ │ ├── TorrentAddViewModel.swift │ │ ├── TorrentDetailsViewModel.swift │ │ ├── TorrentListHelperViewModel.swift │ │ ├── TorrentListViewModel.swift │ │ └── TrackersViewModel.swift ├── Views │ ├── MainView.swift │ ├── RSSViews │ │ ├── RSSArticleView.swift │ │ ├── RSSFeedView.swift │ │ ├── RSSNodeView.swift │ │ └── RSSView.swift │ ├── SearchViews │ │ ├── SearchFiltersView.swift │ │ ├── SearchRowView.swift │ │ └── SearchView.swift │ ├── ServersViews │ │ ├── ServerAddView.swift │ │ ├── ServerRowView.swift │ │ └── ServersView.swift │ └── TorrentViews │ │ ├── AboutView.swift │ │ ├── FilesView.swift │ │ ├── FiltersMenuView.swift │ │ ├── PeerDetailsView.swift │ │ ├── PeersView.swift │ │ ├── StatsView.swift │ │ ├── TorrentAddView.swift │ │ ├── TorrentDetailsView.swift │ │ ├── TorrentListHelperView.swift │ │ ├── TorrentListView.swift │ │ └── TrackersView.swift ├── qBitControl.entitlements ├── qBitControlApp.swift ├── qBitControlTests-Bridging-Header.h └── qBitControlUITests-Bridging-Header.h └── qBitControlUITests ├── qBitControlUITests.swift └── qBitControlUITestsLaunchTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: michael128 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: Michael-128 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Smartphone (please complete the following information):** 27 | - Device: [e.g. iPhone6] 28 | - OS: [e.g. iOS8.1] 29 | - Browser [e.g. stock browser, safari] 30 | - Version [e.g. 22] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature]" 5 | labels: enhancement 6 | assignees: Michael-128 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/automated-ios-build.yml: -------------------------------------------------------------------------------- 1 | name: Automated iOS Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | tags: 8 | - "build-ci*" 9 | pull_request: 10 | branches: 11 | - "main" 12 | 13 | jobs: 14 | build: 15 | runs-on: macos-14 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: maxim-lobanov/setup-xcode@v1 19 | with: 20 | xcode-version: '15.4' 21 | - uses: yukiarrr/ios-build-action@v1.11.2 22 | with: 23 | project-path: qBitControl.xcodeproj 24 | p12-base64: ${{ secrets.P12_BASE64 }} 25 | mobileprovision-base64: ${{ secrets.MOBILEPROVISION_BASE64 }} 26 | code-signing-identity: ${{ secrets.CODE_SIGNING_IDENTITY }} 27 | team-id: ${{ secrets.TEAM_ID }} 28 | output-path: qBitControl.ipa 29 | - name: Extract Version and Build Number 30 | if: startsWith(github.ref, 'refs/tags/build-ci') 31 | id: extract_version 32 | run: | 33 | TAG="${GITHUB_REF#refs/tags/}" 34 | echo "Full Tag: $TAG" 35 | VERSION=$(echo "$TAG" | cut -d '-' -f 3) 36 | BUILD_NUMBER=$(echo "$TAG" | cut -d '-' -f 4) 37 | echo "Extracted Version: $VERSION" 38 | echo "Extracted Build Number: $BUILD_NUMBER" 39 | echo "VERSION=$VERSION" >> $GITHUB_ENV 40 | echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV 41 | - name: "Update Release" 42 | if: startsWith(github.ref, 'refs/tags/build-ci') 43 | uses: "marvinpinto/action-automatic-releases@latest" 44 | with: 45 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 46 | prerelease: true 47 | automatic_release_tag: "CI" 48 | title: "qBitControl ${{ env.VERSION }} (${{ env.BUILD_NUMBER }})" 49 | files: | 50 | qBitControl.ipa 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "qBitControl", 8 | products: [ 9 | // Products define the executables and libraries a package produces, making them visible to other packages. 10 | .library( 11 | name: "qBitControl", 12 | targets: ["qBitControl"]), 13 | ], 14 | targets: [ 15 | // Targets are the basic building blocks of a package, defining a module or a test suite. 16 | // Targets can depend on other targets in this package and products from dependencies. 17 | .target( 18 | name: "qBitControl"), 19 | .testTarget( 20 | name: "qBitControlTests", 21 | dependencies: ["qBitControl"]), 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qBitControl [![iOS Build](https://github.com/Michael-128/qBitControl/actions/workflows/automated-ios-build.yml/badge.svg?branch=main)](https://github.com/Michael-128/qBitControl/actions/workflows/automated-ios-build.yml) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/Michael-128/qBitControl/total) 2 | 3 | 4 | qBittorrent remote client for iOS devices. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | ## Now available on AltStore PAL! 📱 20 | qBitControl is now available on AltStore PAL, allowing users located in the EU to get and update the app seamlessly, as well as support its development. 21 | 22 | Get qBitControl by adding Michael-128 source from the list of recommended sources or via pasting the link below: 23 | - AltStore PAL: `https://raw.githubusercontent.com/Michael-128/qBitControl-releases/main/source.json` 24 | - AltStore: `https://raw.githubusercontent.com/Michael-128/qBitControl-releases/main/source-classic.json` 25 | 26 | ## Features ✨ 27 | - Add torrents via .torrent files or magnet links. 28 | - Monitor download progress. 29 | - Manage torrents - pause/resume, recheck, reannounce and delete. 30 | - Browse & manage torrent files. 31 | - Real-time statistics. 32 | - Native iOS user interface. 33 | 34 | To request a feature or report a bug, please open an [issue](https://github.com/Michael-128/qBitControl/issues) on GitHub. 35 | 36 | 37 | ## Building 🛠️ 38 | - Install Xcode from [App Store](https://apps.apple.com/us/app/xcode/id497799835). 39 | - Clone the repository. 40 | - `git clone https://github.com/Michael-128/qBitControl` 41 | - Open qBitControl.xcodeproj in Xcode. 42 | - Click on "qBitControl" at the top of the left sidebar. 43 | - Click on the "Signing & Capabilities" tab. 44 | - Under "Targets", select "qBitControl". 45 | - In the "Team" dropdown, select your account. 46 | - Change the "Bundle Identifier" to something else. 47 | - Select your Apple device in the top bar as a target and click the Play button to run. 48 | 49 | 50 | ## Support 🤝 51 | If you like qBitControl, please consider supporting the development by contributing to the repository or subscribing to my Patreon. Thank you! 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /Sources/qBitControl/qBitControl.swift: -------------------------------------------------------------------------------- 1 | // The Swift Programming Language 2 | // https://docs.swift.org/swift-book 3 | -------------------------------------------------------------------------------- /Tests/qBitControlTests/qBitControlTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import qBitControl 3 | 4 | final class qBitControlTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documenation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /qBitControl.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /qBitControl.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /qBitControl.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /qBitControl.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /qBitControl.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "logo1.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/AppIcon.appiconset/logo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-128/qBitControl/11d7c5d4d30d5338cab1d79997ed64f81491124a/qBitControl/Assets.xcassets/AppIcon.appiconset/logo1.png -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "logo1.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "logo1 1.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "logo1 2.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/logo.imageset/logo1 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-128/qBitControl/11d7c5d4d30d5338cab1d79997ed64f81491124a/qBitControl/Assets.xcassets/logo.imageset/logo1 1.png -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/logo.imageset/logo1 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-128/qBitControl/11d7c5d4d30d5338cab1d79997ed64f81491124a/qBitControl/Assets.xcassets/logo.imageset/logo1 2.png -------------------------------------------------------------------------------- /qBitControl/Assets.xcassets/logo.imageset/logo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Michael-128/qBitControl/11d7c5d4d30d5338cab1d79997ed64f81491124a/qBitControl/Assets.xcassets/logo.imageset/logo1.png -------------------------------------------------------------------------------- /qBitControl/Classes/AppInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import Foundation 4 | import SwiftUI 5 | 6 | class AppInfo: ObservableObject { 7 | static let shared = AppInfo() 8 | 9 | @Published public var version: String 10 | @Published public var build: String 11 | 12 | init() { 13 | let infoDictionary = Bundle.main.infoDictionary 14 | 15 | version = infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 16 | build = infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /qBitControl/Classes/AuthClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthClass.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | class Auth { 9 | static private var cookies: [String: String] = [:] 10 | 11 | static func getCookie(id: String) -> String { 12 | return cookies[id] ?? "" 13 | } 14 | 15 | static func setCookie(id: String, cookie: String) { 16 | cookies[id] = cookie 17 | } 18 | 19 | static func getCookie(url: String, username: String, password: String, isSuccess: @escaping (Bool) -> Void, setCookie: Bool = true) async { 20 | let urlString = url 21 | guard let url = URL(string: "\(url)/api/v2/auth/login") else { return } 22 | 23 | var req = URLRequest(url: url) 24 | req.httpMethod = "POST" 25 | 26 | // Create a properly encoded query string 27 | let parameters: [String: String] = [ 28 | "username": username, 29 | "password": password 30 | ] 31 | 32 | let parameterArray = parameters.map { key, value in 33 | return "\(key)=\(value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "")" 34 | } 35 | let bodyString = parameterArray.joined(separator: "&") 36 | req.httpBody = bodyString.data(using: .utf8) 37 | 38 | let sessionConfiguration = URLSessionConfiguration.default 39 | sessionConfiguration.timeoutIntervalForRequest = 10 40 | let session = URLSession(configuration: sessionConfiguration) 41 | 42 | await session.reset() 43 | 44 | session.dataTask(with: req) { data, response, error in 45 | if let response = response as? HTTPURLResponse { 46 | let cookie = String(String(describing: response.allHeaderFields["Set-Cookie"] ?? "n/a;").split(separator: ";")[0]) 47 | if cookie.contains("SID") { 48 | if setCookie { 49 | qBittorrent.setURL(url: urlString) 50 | qBittorrent.setCookie(cookie: cookie) 51 | } 52 | isSuccess(true) 53 | } else { 54 | isSuccess(false) 55 | } 56 | } 57 | if let error = error { 58 | print(error.localizedDescription) 59 | isSuccess(false) 60 | } 61 | }.resume() 62 | } 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /qBitControl/Classes/FileNodeClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileNodeClass.swift 3 | // qBitControl 4 | // 5 | 6 | import Foundation 7 | 8 | 9 | final class FileNode: Identifiable, ObservableObject { 10 | let id = UUID() 11 | var index: Int? // File index 12 | let name: String // File name (including relative path) 13 | var size: Int64? // File size (bytes) 14 | var progress: Float? // File progress (percentage/100) 15 | var priority: Int? // File priority. See possible values here below 16 | var is_seed: Bool? // True if file is seeding/complete 17 | //var piece_range: [Int]?// The first number is the starting piece index and the second number is the ending piece index (inclusive) 18 | var availability: Float? // Percentage of file pieces currently available (percentage/100) 19 | 20 | //weak var parent: FileNode? 21 | 22 | let isDir: Bool 23 | 24 | var children: [FileNode]? 25 | 26 | init(index: Int, name: String, size: Int64, progress: Float, priority: Int, is_seed: Bool?, availability: Float) { 27 | self.isDir = false 28 | //self.children = [] 29 | self.index = index 30 | self.name = name 31 | self.size = size 32 | self.progress = progress 33 | self.priority = priority 34 | self.is_seed = is_seed 35 | //self.piece_range = piece_range 36 | self.availability = availability 37 | } 38 | 39 | init(name: String) { 40 | self.isDir = true 41 | self.name = name 42 | } 43 | 44 | func add(child: FileNode) { 45 | if children == nil {self.children = []} 46 | children?.append(child) 47 | } 48 | 49 | func addMultiple(children: [FileNode]) { 50 | self.children = children 51 | } 52 | } 53 | 54 | extension FileNode: CustomStringConvertible { 55 | var description: String { 56 | var text = "\(name)" 57 | if !(children ?? []).isEmpty { 58 | text += "\n{\n - " + (children ?? []).map { $0.description }.joined(separator: "\n - ") + "\n} " 59 | } 60 | return text 61 | } 62 | } 63 | 64 | extension FileNode { 65 | func getIndexes() -> [Int] { 66 | if !self.isDir { 67 | if let index = self.index { 68 | return [index] 69 | } 70 | } 71 | 72 | var indexes: [Int] = [] 73 | 74 | for child in (children ?? []) { 75 | indexes += child.getIndexes() 76 | } 77 | 78 | return indexes 79 | } 80 | 81 | func getSize() -> Int64 { 82 | if !self.isDir { 83 | return size ?? 0 84 | } 85 | 86 | var size: Int64 = 0 87 | 88 | for child in (children ?? []) { 89 | size += child.getSize() 90 | } 91 | 92 | return size 93 | } 94 | 95 | func getDirCount() -> Int { 96 | var dirs = 0 97 | 98 | if self.isDir { 99 | dirs += 1 100 | } 101 | 102 | for child in (children ?? []) { 103 | dirs += child.getDirCount() 104 | } 105 | 106 | return dirs 107 | } 108 | 109 | func getFileCount() -> Int { 110 | var files = 0 111 | 112 | if !self.isDir { 113 | files += 1 114 | } 115 | 116 | for child in (children ?? []) { 117 | files += child.getFileCount() 118 | } 119 | 120 | return files 121 | } 122 | 123 | func getPriority() -> Int { 124 | if let priority = priority { 125 | return priority 126 | } 127 | 128 | for child in (children ?? []) { 129 | let priority = child.getPriority() 130 | if priority > 0 {return priority} 131 | } 132 | 133 | return 0 134 | } 135 | 136 | func setPriority(priority: Int) { 137 | self.priority = priority 138 | } 139 | 140 | func search(index: Int) -> FileNode? { 141 | if index == self.index { 142 | return self 143 | } 144 | 145 | for child in (children ?? []) { 146 | if let found = child.search(index: index) { 147 | return found 148 | } 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func findAll(query: String) -> [FileNode] { 155 | var hits: [FileNode] = [] 156 | 157 | if self.name.lowercased().contains(query.lowercased()) { 158 | return [self] 159 | } 160 | 161 | for child in (children ?? []) { 162 | hits += child.findAll(query: query) 163 | } 164 | 165 | return hits 166 | } 167 | 168 | 169 | // 1 170 | func search(name: String) -> FileNode? { 171 | // 2 172 | if name == self.name { 173 | return self 174 | } 175 | // 3 176 | for child in (children ?? []) { 177 | if let found = child.search(name: name) { 178 | return found 179 | } 180 | } 181 | // 4 182 | return nil 183 | } 184 | 185 | // 1 186 | func shallowSearch(name: String) -> FileNode? { 187 | // 2 188 | if name == self.name { 189 | return self 190 | } 191 | // 3 192 | for child in (children ?? []) { 193 | if name == child.name { 194 | return child 195 | } 196 | } 197 | // 4 198 | return nil 199 | } 200 | } 201 | 202 | extension FileNode 203 | { 204 | func treeLines(_ nodeIndent:String="", _ childIndent:String="") -> [String] 205 | { 206 | return [ nodeIndent + self.name ] 207 | + (children ?? []).enumerated().map{ ($0 < (children ?? []).count-1, $1) } 208 | .flatMap{ $0 ? $1.treeLines("┣╸","┃ ") : $1.treeLines("┗╸"," ") } 209 | .map{ childIndent + $0 } 210 | } 211 | 212 | func printTree() 213 | { print(treeLines().joined(separator:"\n")) } 214 | } 215 | -------------------------------------------------------------------------------- /qBitControl/Classes/LocalNetworkPermissionClass.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Network 3 | 4 | 5 | class LocalNetworkPermissionService { 6 | 7 | private let port: UInt16 8 | private var interfaces: [String] = [] 9 | private var connections: [NWConnection] = [] 10 | 11 | 12 | init() { 13 | self.port = 12345 14 | self.interfaces = ipv4AddressesOfEthernetLikeInterfaces() 15 | } 16 | 17 | deinit { 18 | connections.forEach { $0.cancel() } 19 | } 20 | 21 | // This method try to connect to iPhone self IP Address 22 | func triggerDialog() { 23 | for interface in interfaces { 24 | let host = NWEndpoint.Host(interface) 25 | let port = NWEndpoint.Port(integerLiteral: self.port) 26 | let connection = NWConnection(host: host, port: port, using: .udp) 27 | connection.stateUpdateHandler = { [weak self, weak connection] state in 28 | self?.stateUpdateHandler(state, connection: connection) 29 | } 30 | connection.start(queue: .main) 31 | connections.append(connection) 32 | } 33 | } 34 | 35 | // MARK: Private API 36 | 37 | private func stateUpdateHandler(_ state: NWConnection.State, connection: NWConnection?) { 38 | switch state { 39 | case .waiting: 40 | let content = "Hello Cruel World!".data(using: .utf8) 41 | connection?.send(content: content, completion: .idempotent) 42 | default: 43 | break 44 | } 45 | } 46 | 47 | private func namesOfEthernetLikeInterfaces() -> [String] { 48 | var addrList: UnsafeMutablePointer? = nil 49 | let err = getifaddrs(&addrList) 50 | guard err == 0, let start = addrList else { return [] } 51 | defer { freeifaddrs(start) } 52 | return sequence(first: start, next: { $0.pointee.ifa_next }) 53 | .compactMap { i -> String? in 54 | guard 55 | let sa = i.pointee.ifa_addr, 56 | sa.pointee.sa_family == AF_LINK, 57 | let data = i.pointee.ifa_data?.assumingMemoryBound(to: if_data.self), 58 | data.pointee.ifi_type == IFT_ETHER 59 | else { 60 | return nil 61 | } 62 | return String(cString: i.pointee.ifa_name) 63 | } 64 | } 65 | 66 | private func ipv4AddressesOfEthernetLikeInterfaces() -> [String] { 67 | let interfaces = Set(namesOfEthernetLikeInterfaces()) 68 | 69 | print("Interfaces: \(interfaces)") 70 | var addrList: UnsafeMutablePointer? = nil 71 | let err = getifaddrs(&addrList) 72 | guard err == 0, let start = addrList else { return [] } 73 | defer { freeifaddrs(start) } 74 | return sequence(first: start, next: { $0.pointee.ifa_next }) 75 | .compactMap { i -> String? in 76 | guard 77 | let sa = i.pointee.ifa_addr, 78 | sa.pointee.sa_family == AF_INET 79 | else { 80 | return nil 81 | } 82 | let name = String(cString: i.pointee.ifa_name) 83 | guard interfaces.contains(name) else { return nil } 84 | var addr = [CChar](repeating: 0, count: Int(NI_MAXHOST)) 85 | let err = getnameinfo(sa, socklen_t(sa.pointee.sa_len), &addr, socklen_t(addr.count), nil, 0, NI_NUMERICHOST | NI_NUMERICSERV) 86 | guard err == 0 else { return nil } 87 | let address = String(cString: addr) 88 | print("Address: \(address)") 89 | return address 90 | } 91 | } 92 | 93 | 94 | } 95 | -------------------------------------------------------------------------------- /qBitControl/Classes/ServersHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import Foundation 4 | 5 | 6 | class ServersHelper: ObservableObject { 7 | static public var shared = ServersHelper() 8 | 9 | private var defaults = UserDefaults.standard 10 | private let serversKey = "servers" 11 | private let activeServerKey = "activeServer" 12 | 13 | @Published public var servers: [Server] = [] 14 | @Published public var activeServerId: String? 15 | @Published public var connectingServerId: String? 16 | 17 | @Published public var isLoggedIn = false 18 | 19 | init() { 20 | getServerList() 21 | getActiveServer() 22 | 23 | if let activeServerId = self.activeServerId { 24 | if let activeServer = self.getServer(id: activeServerId) { 25 | self.connect(server: activeServer) 26 | } 27 | } 28 | } 29 | 30 | func getServerList() { 31 | let encodedServers = defaults.data(forKey: self.serversKey) 32 | 33 | if let encodedServers = encodedServers { 34 | let decoder = JSONDecoder() 35 | 36 | do { 37 | self.servers = try decoder.decode([Server].self, from: encodedServers) 38 | } catch { 39 | print("Servers could not be decoded.") 40 | } 41 | } 42 | } 43 | 44 | func getServer(id: String) -> Server? { 45 | return servers.first(where: { 46 | server in 47 | return server.id == id 48 | }) 49 | } 50 | 51 | private func setActiveServer(id: String) { 52 | self.activeServerId = id 53 | defaults.setValue("\(id)", forKey: activeServerKey) 54 | } 55 | 56 | private func getActiveServer() { 57 | let serverId = defaults.string(forKey: activeServerKey) 58 | 59 | if let serverId = serverId { 60 | self.activeServerId = self.servers.first(where: { 61 | server in 62 | server.id == serverId 63 | })?.id 64 | } 65 | } 66 | 67 | func saveSeverList() { 68 | let encoder = JSONEncoder() 69 | 70 | do { 71 | let encodedServers = try encoder.encode(self.servers) 72 | defaults.setValue(encodedServers, forKey: self.serversKey) 73 | } catch { 74 | print("Servers could not be encoded") 75 | } 76 | } 77 | 78 | func addServer(server: Server) { 79 | self.servers.append(server) 80 | saveSeverList() 81 | } 82 | 83 | func removeServer(id: String) { 84 | self.servers.removeAll(where: { 85 | server in 86 | return server.id == id 87 | }) 88 | 89 | if(id == activeServerId) { 90 | activeServerId = nil 91 | isLoggedIn = false 92 | } 93 | 94 | saveSeverList() 95 | } 96 | 97 | func checkConnection(server: Server, result: @escaping (Bool) -> Void) { 98 | Task { 99 | await Auth.getCookie(url: server.url, username: server.username, password: server.password, isSuccess: { 100 | success in 101 | result(success); 102 | }, setCookie: false) 103 | } 104 | } 105 | 106 | func connect(server: Server, result: ((Bool) -> Void)?) { 107 | connectingServerId = server.id 108 | 109 | Task { 110 | await Auth.getCookie(url: server.url, username: server.username, password: server.password, isSuccess: { 111 | success in 112 | DispatchQueue.main.async { 113 | if let result = result { 114 | result(success) 115 | } 116 | 117 | if(success) { 118 | self.setActiveServer(id: server.id) 119 | qBittorrent.initialize() 120 | self.isLoggedIn = true 121 | } 122 | 123 | self.connectingServerId = nil 124 | } 125 | }) 126 | } 127 | } 128 | 129 | func connect(server: Server) { 130 | connectingServerId = server.id 131 | 132 | Task { 133 | await Auth.getCookie(url: server.url, username: server.username, password: server.password, isSuccess: { 134 | success in 135 | DispatchQueue.main.async { 136 | if(success) { 137 | self.setActiveServer(id: server.id) 138 | qBittorrent.initialize() 139 | self.isLoggedIn = true 140 | } 141 | 142 | self.connectingServerId = nil 143 | } 144 | }) 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /qBitControl/Classes/qBitDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | import SwiftUI 3 | import Foundation 4 | 5 | class qBitData: ObservableObject { 6 | static let shared = qBitData() 7 | 8 | var rid = 0 9 | @Published var serverState: ServerState? 10 | @Published var dlTransferData: [TransferInfo] = [] 11 | @Published var upTransferData: [TransferInfo] = [] 12 | 13 | private var timer: Timer? 14 | private var fetchInterval: TimeInterval = 2 15 | 16 | init() { 17 | let date = Date() 18 | 19 | for n in stride(from: -30, to: 0, by: 2) { 20 | dlTransferData.append(TransferInfo(fetchDate: date.addingTimeInterval(Double(n)), info_speed: 0)) 21 | 22 | upTransferData.append(TransferInfo(fetchDate: date.addingTimeInterval(Double(n)), info_speed: 0)) 23 | } 24 | 25 | self.getMainData() 26 | 27 | timer = Timer.scheduledTimer(withTimeInterval: fetchInterval, repeats: true) { 28 | _ in 29 | self.getMainData() 30 | } 31 | } 32 | 33 | func getMainData() { 34 | qBittorrent.getMainData(rid: rid) { mainData in 35 | DispatchQueue.main.async { 36 | self.rid = mainData.rid 37 | 38 | if let partialServerState = mainData.server_state { 39 | if let existingServerState = self.serverState { 40 | // Update existing ServerState with new data 41 | var updatedServerState = existingServerState 42 | updatedServerState.update(from: partialServerState) 43 | self.serverState = updatedServerState 44 | } else if let newServerState = ServerState(from: partialServerState) { 45 | // Create a new ServerState if none exists 46 | self.serverState = newServerState 47 | } 48 | 49 | 50 | let newDlTransferInfo = TransferInfo(fetchDate: Date(), info_speed: partialServerState.dl_info_speed ?? 0) 51 | self.dlTransferData.append(newDlTransferInfo) 52 | 53 | // Limit the history to the last 30 entries 54 | if self.dlTransferData.count > 20 { self.dlTransferData.removeFirst(self.dlTransferData.count - 20) } 55 | 56 | let newUpTransferInfo = TransferInfo(fetchDate: Date(), info_speed: partialServerState.up_info_speed ?? 0) 57 | self.upTransferData.append(newUpTransferInfo) 58 | 59 | // Limit the history to the last 30 entries 60 | if self.upTransferData.count > 20 { self.upTransferData.removeFirst(self.upTransferData.count - 20) } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /qBitControl/Classes/qBitRequestClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // qBitRequestClass.swift 3 | // qBitControl 4 | // 5 | 6 | import Foundation 7 | 8 | 9 | class qBitRequest { 10 | static func prepareURLRequest(path: String, queryItems: [URLQueryItem]) -> URLRequest { 11 | let cookie = qBittorrent.getCookie() 12 | let url = qBittorrent.getURL() 13 | if(cookie == "n/a") {print("Invalid cookie!")} 14 | 15 | guard let url = URL(string: "\(url)\(path)") else {fatalError("Invalid URL!")} 16 | 17 | let jar = HTTPCookieStorage.shared 18 | let cookieHeaderField = ["Set-Cookie": cookie] // Or ["Set-Cookie": "key=value, key2=value2"] for multiple cookies 19 | let cookies = HTTPCookie.cookies(withResponseHeaderFields: cookieHeaderField, for: url) 20 | jar.setCookies(cookies, for: url, mainDocumentURL: url) 21 | 22 | var req = URLRequest(url: url) 23 | req.httpMethod = "POST" 24 | 25 | var urlComponents = URLComponents() 26 | urlComponents.queryItems = queryItems 27 | 28 | let bodyString = urlComponents.string 29 | guard let bodyString = bodyString?.suffix((bodyString?.count ?? 1) - 1) else { fatalError("Invalid request body!") } 30 | let data = bodyString.data(using: .utf8) 31 | req.httpBody = data 32 | 33 | return req 34 | } 35 | 36 | static func prepareURLRequest(path: String) -> URLRequest { 37 | let cookie = qBittorrent.getCookie() 38 | let url = qBittorrent.getURL() 39 | if(cookie == "n/a") {print("Invalid cookie!")} 40 | 41 | guard let url = URL(string: "\(url)\(path)") else {fatalError("Invalid URL!")} 42 | 43 | let jar = HTTPCookieStorage.shared 44 | let cookieHeaderField = ["Set-Cookie": cookie] // Or ["Set-Cookie": "key=value, key2=value2"] for multiple cookies 45 | let cookies = HTTPCookie.cookies(withResponseHeaderFields: cookieHeaderField, for: url) 46 | jar.setCookies(cookies, for: url, mainDocumentURL: url) 47 | 48 | let req = URLRequest(url: url) 49 | 50 | return req 51 | } 52 | 53 | static func requestTorrentListJSON(request: URLRequest, completionHandler: @escaping ([Torrent]) -> Void) { 54 | URLSession.shared.dataTask(with: request) { 55 | data, response, error in 56 | if let data = data { 57 | do { 58 | let json = try JSONDecoder().decode([Torrent].self, from: data) 59 | completionHandler(json) 60 | } catch { 61 | print(error) 62 | } 63 | } 64 | }.resume() 65 | } 66 | 67 | static func requestUniversal(request: URLRequest) { 68 | URLSession.shared.dataTask(with: request) { 69 | data, response, error in 70 | }.resume() 71 | } 72 | 73 | static func requestTorrentManagement(request: URLRequest, statusCode: @escaping (Int?) -> Void) { 74 | URLSession.shared.dataTask(with: request) { 75 | data, response, error in 76 | if let response = response as? HTTPURLResponse { 77 | statusCode(response.statusCode) 78 | return 79 | } 80 | statusCode(nil) 81 | }.resume() 82 | } 83 | 84 | static func requestPreferencesJSON(request: URLRequest, completionHandler: @escaping (qBitPreferences) -> Void) { 85 | URLSession.shared.dataTask(with: request) { 86 | data, response, error in 87 | if let data = data { 88 | do { 89 | let json = try JSONDecoder().decode(qBitPreferences.self, from: data) 90 | completionHandler(json) 91 | } catch { 92 | print(error) 93 | } 94 | } 95 | }.resume() 96 | } 97 | 98 | static func requestSearchStart(request: URLRequest, completionHandler: @escaping (SearchStartResult) -> Void) { 99 | URLSession.shared.dataTask(with: request) { 100 | data, response, error in 101 | if let data = data { 102 | do { 103 | let json = try JSONDecoder().decode(SearchStartResult.self, from: data) 104 | completionHandler(json) 105 | } catch { 106 | print(error) 107 | } 108 | } 109 | }.resume() 110 | } 111 | 112 | static func requestSearchResults(request: URLRequest, completionHandler: @escaping (SearchResponse) -> Void) { 113 | URLSession.shared.dataTask(with: request) { 114 | data, response, error in 115 | if let data = data { 116 | do { 117 | let json = try JSONDecoder().decode(SearchResponse.self, from: data) 118 | completionHandler(json) 119 | } catch { 120 | print(error) 121 | } 122 | } 123 | }.resume() 124 | } 125 | 126 | static func requestSearchPlugins(request: URLRequest, completionHandler: @escaping ([SearchPlugin]) -> Void) { 127 | URLSession.shared.dataTask(with: request) { 128 | data, response, error in 129 | if let data = data { 130 | do { 131 | let json = try JSONDecoder().decode([SearchPlugin].self, from: data) 132 | completionHandler(json) 133 | } catch { 134 | print(error) 135 | } 136 | } 137 | }.resume() 138 | } 139 | 140 | static func requestGlobalTransferInfo(request: URLRequest, completionHandler: @escaping (GlobalTransferInfo) -> Void) { 141 | URLSession.shared.dataTask(with: request) { 142 | data, response, error in 143 | if let data = data { 144 | do { 145 | let json = try JSONDecoder().decode(GlobalTransferInfo.self, from: data) 146 | completionHandler(json) 147 | } catch { 148 | print(error) 149 | } 150 | } 151 | }.resume() 152 | } 153 | 154 | static func requestMainData(request: URLRequest, completionHandler: @escaping (MainData) -> Void) { 155 | URLSession.shared.dataTask(with: request) { 156 | data, response, error in 157 | if let data = data { 158 | do { 159 | let json = try JSONDecoder().decode(MainData.self, from: data) 160 | completionHandler(json) 161 | } catch { 162 | print(error) 163 | } 164 | } 165 | }.resume() 166 | } 167 | 168 | 169 | static func requestPeersJSON(request: URLRequest, completionHandler: @escaping (Peers) -> Void) { 170 | URLSession.shared.dataTask(with: request) { 171 | data, response, error in 172 | if let data = data { 173 | do { 174 | let json = try JSONDecoder().decode(Peers.self, from: data) 175 | completionHandler(json) 176 | } catch { 177 | print(error) 178 | } 179 | } 180 | }.resume() 181 | } 182 | 183 | static func requestTrackersJSON(request: URLRequest, completionHandler: @escaping ([Tracker]) -> Void) { 184 | URLSession.shared.dataTask(with: request) { 185 | data, response, error in 186 | if let data = data { 187 | do { 188 | let json = try JSONDecoder().decode([Tracker].self, from: data) 189 | completionHandler(json) 190 | } catch { 191 | print(error) 192 | } 193 | } 194 | }.resume() 195 | } 196 | 197 | static func requestFilesJSON(request: URLRequest, completionHandler: @escaping ([File]) -> Void) { 198 | URLSession.shared.dataTask(with: request) { 199 | data, response, error in 200 | if let data = data { 201 | do { 202 | let json = try JSONDecoder().decode([File].self, from: data) 203 | completionHandler(json) 204 | } catch { 205 | print(error) 206 | } 207 | } 208 | }.resume() 209 | } 210 | 211 | static func requestCategoriesJSON(request: URLRequest, completionHandler: @escaping ([String: Category]) -> Void) { 212 | URLSession.shared.dataTask(with: request) { 213 | data, response, error in 214 | if let data = data { 215 | do { 216 | let json = try JSONDecoder().decode([String: Category].self, from: data) 217 | completionHandler(json) 218 | } catch { 219 | print(error) 220 | } 221 | } 222 | }.resume() 223 | } 224 | 225 | static func requestVersion(request: URLRequest, completionHandler: @escaping (Version) -> Void) { 226 | URLSession.shared.dataTask(with: request) { 227 | data, response, error in 228 | if let data = data, let versionData = String(data: data, encoding: .utf8) { 229 | let versionString = versionData.filter { "0123456789.".contains($0) } 230 | let versionParts = versionString.split(separator: ".").map { Int($0) ?? 0 } 231 | if versionParts.count >= 3 { 232 | let versionModel = Version(major: versionParts[0], minor: versionParts[1], patch: versionParts[2]) 233 | completionHandler(versionModel) 234 | } 235 | } 236 | }.resume() 237 | } 238 | 239 | static func requestTagsJSON(request: URLRequest, completionHandler: @escaping ([String]) -> Void) { 240 | URLSession.shared.dataTask(with: request) { 241 | data, response, error in 242 | if let data = data { 243 | do { 244 | let json = try JSONDecoder().decode([String].self, from: data) 245 | completionHandler(json) 246 | } catch { 247 | print(error) 248 | } 249 | } 250 | }.resume() 251 | } 252 | 253 | static func requestRSSFeedJSON(request: URLRequest, completion: @escaping (RSSNode) -> Void) { 254 | URLSession.shared.dataTask(with: request) { 255 | data, response, error in 256 | 257 | if let data = data { 258 | do { 259 | try completion(JSONDecoder().decode(RSSNode.self, from: data)) 260 | } catch { 261 | print(error) 262 | } 263 | } 264 | }.resume() 265 | } 266 | 267 | } 268 | -------------------------------------------------------------------------------- /qBitControl/Components/ChangeCategoryView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChangeCategoryView: View { 4 | @State var torrentHash: String? 5 | @State private var categories: [Category] = [] 6 | @State var category: String 7 | 8 | let defaultCategory: Category = Category(name: NSLocalizedString("Uncategorized", comment: ""), savePath: "") 9 | func isDefaultCategorySelected(currentCategory: String) -> Bool { 10 | return currentCategory == defaultCategory.name && category == "" 11 | } 12 | 13 | @State private var showAddCategoryAlert = false 14 | @State private var newCategoryName = "" 15 | 16 | public var onCategoryChange: ((Category) -> Void)? 17 | 18 | private func getCategories() { 19 | qBittorrent.getCategories(completionHandler: { _categories in 20 | var categories = _categories.map { $0.value } 21 | categories.sort { $0.name < $1.name } 22 | categories.insert(defaultCategory, at: 0) 23 | self.categories = categories 24 | clearSelectedCategories() 25 | }) 26 | } 27 | 28 | private func clearSelectedCategories() { 29 | if let onCategoryChange = self.onCategoryChange { 30 | if !categories.map({ $0.name }).contains(category) { 31 | onCategoryChange(defaultCategory) 32 | } 33 | } 34 | } 35 | 36 | var body: some View { 37 | VStack { 38 | Form { 39 | Section(header: Text("Add Category")) { 40 | Button { 41 | showAddCategoryAlert = true 42 | } label: { 43 | Label("Add Category", systemImage: "plus.circle") 44 | }.alert("Add New Category", isPresented: $showAddCategoryAlert, actions: { 45 | TextField("Category Name", text: $newCategoryName) 46 | Button("Add", action: { 47 | qBittorrent.addCategory(category: newCategoryName, savePath: nil, then: { status in 48 | if(status == 200) { 49 | self.getCategories() 50 | } 51 | }) 52 | newCategoryName = "" 53 | }) 54 | Button("Cancel", role: .cancel, action: { 55 | newCategoryName = "" 56 | }) 57 | }) 58 | } 59 | 60 | if categories.count > 1 { 61 | Section(header: Text("Categories")) { 62 | List { 63 | ForEach(categories, id: \.self) { category in 64 | Button { 65 | if(self.category != category.name) { self.category = category.name } 66 | } label: { 67 | HStack { 68 | Text(category.name) 69 | .foregroundStyle(.foreground) 70 | Spacer() 71 | if(self.category == category.name || isDefaultCategorySelected(currentCategory: category.name)) { 72 | Image(systemName: "checkmark") 73 | .foregroundColor(.accentColor) 74 | } 75 | } 76 | } 77 | } 78 | .onDelete(perform: { offsets in 79 | for index in offsets { 80 | let category = categories[index].name 81 | qBittorrent.removeCategory(category: category, then: {status in print(status)}) 82 | } 83 | 84 | categories.remove(atOffsets: offsets) 85 | self.clearSelectedCategories() 86 | }) 87 | } 88 | } 89 | } 90 | } 91 | .navigationTitle("Categories") 92 | }.onAppear() { 93 | print(category) 94 | self.getCategories() 95 | }.onChange(of: category) { category in 96 | if let onCategoryChange = self.onCategoryChange, let category = categories.first(where: { $0.name == category }) { onCategoryChange(category) } 97 | if let hash = self.torrentHash { qBittorrent.setCategory(hash: hash, category: category == defaultCategory.name ? "" : category) } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /qBitControl/Components/ChangePathView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChangePathView: View { 4 | @Environment(\.presentationMode) var presentationMode 5 | @State var path: String 6 | let torrentHash: String 7 | 8 | func setPath() { 9 | let request = qBitRequest.prepareURLRequest(path: "/api/v2/torrents/setLocation", queryItems: [ 10 | URLQueryItem(name: "hashes", value: torrentHash), 11 | URLQueryItem(name: "location", value: path) 12 | ]) 13 | 14 | qBitRequest.requestTorrentManagement(request: request, statusCode: { 15 | code in 16 | print("Code: \(code ?? -1)") 17 | }) 18 | } 19 | 20 | var body: some View { 21 | Form { 22 | Section { 23 | TextField("Save Path", text: $path, axis: .vertical) 24 | .lineLimit(1...5) 25 | } 26 | 27 | Section { 28 | Button { 29 | setPath() 30 | presentationMode.wrappedValue.dismiss() 31 | } label: { 32 | Text("Update") 33 | } 34 | } 35 | }.navigationTitle("Save Path") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /qBitControl/Components/ChangeTagsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChangeTagsView: View { 4 | @State var torrentHash: String? 5 | @State var selectedTags: Set 6 | 7 | @State private var allTags: [String] = [] 8 | 9 | public var onTagsChange: ((Set) -> Void)? 10 | 11 | @State private var showAddTagAlert = false 12 | @State private var newTagName = "" 13 | 14 | init(torrentHash: String, selectedTags: [String]) { 15 | self.torrentHash = torrentHash 16 | self.selectedTags = Set(selectedTags) 17 | } 18 | 19 | init(selectedTags: Set, onTagsChange: @escaping (Set) -> Void) { 20 | self.selectedTags = selectedTags 21 | self.onTagsChange = onTagsChange 22 | } 23 | 24 | func getTags() { 25 | qBittorrent.getTags(completionHandler: { tags in 26 | self.allTags = tags.sorted() 27 | self.clearSelectedTags() 28 | }) 29 | } 30 | 31 | func unsetTag(tag: String) { 32 | if let hash = self.torrentHash { 33 | qBittorrent.unsetTag(hash: hash, tag: tag, result: { isSuccess in 34 | if(isSuccess) { selectedTags.remove(tag) } 35 | }) 36 | } else { 37 | selectedTags.remove(tag) 38 | } 39 | 40 | if let onTagsChange = self.onTagsChange { 41 | onTagsChange(selectedTags) 42 | } 43 | } 44 | 45 | func setTag(tag: String) { 46 | if let hash = self.torrentHash { 47 | qBittorrent.setTag(hash: hash, tag: tag, result: { isSuccess in 48 | if(isSuccess) { selectedTags.insert(tag) } 49 | }) 50 | } else { 51 | selectedTags.insert(tag) 52 | } 53 | 54 | if let onTagsChange = self.onTagsChange { 55 | onTagsChange(selectedTags) 56 | } 57 | } 58 | 59 | func addTag() { 60 | qBittorrent.addTag(tag: newTagName, then: { status in 61 | if(status == 200) { 62 | self.getTags() 63 | } 64 | }) 65 | } 66 | 67 | func removeTag(tag: String) { 68 | qBittorrent.removeTag(tag: tag, then: { status in 69 | if(status == 200) { 70 | self.getTags() 71 | } 72 | }) 73 | } 74 | 75 | func clearSelectedTags() { 76 | if let onTagsChange = self.onTagsChange { 77 | self.selectedTags = self.selectedTags.filter { tag in 78 | return self.allTags.contains(tag) 79 | } 80 | onTagsChange(selectedTags) 81 | } 82 | } 83 | 84 | 85 | var body: some View { 86 | VStack { 87 | Form { 88 | Section(header: Text("Add Tag")) { 89 | Button { 90 | showAddTagAlert = true 91 | } label: { 92 | Label("Add Tag", systemImage: "plus.circle") 93 | }.alert("Add New Tag", isPresented: $showAddTagAlert, actions: { 94 | TextField("Tag Name", text: $newTagName) 95 | Button("Add", action: { 96 | self.addTag() 97 | newTagName = "" 98 | }) 99 | Button("Cancel", role: .cancel, action: { 100 | newTagName = "" 101 | }) 102 | }) 103 | } 104 | 105 | if allTags.count > 1 { 106 | Section { 107 | List { 108 | ForEach(allTags, id: \.self) { tag in 109 | Button { 110 | if selectedTags.contains(tag) { 111 | unsetTag(tag: tag) 112 | } else { 113 | setTag(tag: tag) 114 | } 115 | } label: { 116 | HStack { 117 | Text(tag) 118 | .foregroundStyle(.foreground) 119 | Spacer() 120 | if selectedTags.contains(tag) { 121 | Image(systemName: "checkmark") 122 | .foregroundColor(.accentColor) 123 | } 124 | } 125 | } 126 | } 127 | 128 | .onDelete(perform: { atOffsets in 129 | atOffsets.forEach { index in 130 | self.removeTag(tag: self.allTags[index]) 131 | } 132 | 133 | self.allTags.remove(atOffsets: atOffsets) 134 | }) 135 | } 136 | } 137 | } 138 | } 139 | .navigationTitle("Tags") 140 | }.onAppear() { 141 | self.getTags() 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /qBitControl/Components/CustomLabelView.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | 5 | struct CustomLabelView: View { 6 | public var label: LocalizedStringKey 7 | public var value: String 8 | 9 | var body: some View { 10 | Button(action: {UIPasteboard.general.string = "\(value)"}) { 11 | HStack { 12 | Text(label) 13 | Spacer() 14 | Text(NSLocalizedString(value, comment: "")) 15 | .foregroundColor(Color.gray) 16 | .lineLimit(1) 17 | } 18 | }.foregroundColor(.primary) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /qBitControl/Components/MenuControlsLabelView.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | 5 | struct MenuControlsLabelView: View { 6 | let text: LocalizedStringKey 7 | let icon: String 8 | 9 | var body: some View { 10 | HStack { 11 | Text(text) 12 | Image(systemName: icon) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /qBitControl/Components/PeersRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsPeersView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct PeerRowView: View { 9 | 10 | @Binding var peer: Peer 11 | 12 | func emojiFlag(regionCode: String) -> String? { 13 | let code = regionCode.uppercased() 14 | 15 | guard Locale.isoRegionCodes.contains(code) else { 16 | return nil 17 | } 18 | 19 | var flagString = "" 20 | for s in code.unicodeScalars { 21 | guard let scalar = UnicodeScalar(127397 + s.value) else { 22 | continue 23 | } 24 | flagString.append(String(scalar)) 25 | } 26 | return flagString 27 | } 28 | 29 | var body: some View { 30 | NavigationLink { 31 | PeerDetailsView(peer: $peer) 32 | } label: { 33 | VStack { 34 | HStack { 35 | Text("\(emojiFlag(regionCode:peer.country_code) ?? "")") 36 | Text("\(peer.ip)") 37 | Spacer() 38 | } 39 | HStack(spacing: 3) { 40 | if peer.client.count > 1 { 41 | Text("\(peer.client)") 42 | Text("•") 43 | } 44 | Image(systemName: "arrow.down") 45 | Text("\(qBittorrent.getFormatedSize(size: peer.dl_speed))/s") 46 | Text("•") 47 | Image(systemName: "arrow.up") 48 | Text("\(qBittorrent.getFormatedSize(size: peer.up_speed))/s") 49 | Spacer() 50 | }.font(.footnote) 51 | .foregroundColor(Color.gray) 52 | .lineLimit(1) 53 | } 54 | } 55 | } 56 | } 57 | 58 | struct TorrentDetailsPeerRowView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | MainView() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /qBitControl/Components/StatsChartView.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | import Charts 5 | 6 | struct StatsChartView: View { 7 | 8 | @Binding public var transferData: [TransferInfo] 9 | public var color: Color = .blue 10 | 11 | var body: some View { 12 | VStack { 13 | Chart(transferData.suffix(30)) { 14 | transferData in 15 | AreaMark( 16 | x: .value("Time", transferData.fetchDate.timeIntervalSinceNow), 17 | y: .value("Transfer", transferData.info_speed), 18 | stacking: .standard 19 | ).interpolationMethod(.monotone) 20 | .mask { RectangleMark() } 21 | .foregroundStyle( 22 | LinearGradient( 23 | gradient: Gradient(colors: [self.color.opacity(0.4), self.color.opacity(0.2)]), 24 | startPoint: .top, 25 | endPoint: .bottom 26 | ) 27 | ) 28 | 29 | LineMark( 30 | x: .value("Time", transferData.fetchDate.timeIntervalSinceNow), 31 | y: .value("Transfer", transferData.info_speed) 32 | ).interpolationMethod(.monotone) 33 | .mask { RectangleMark() } 34 | .foregroundStyle(self.color) 35 | }.chartXScale(domain: -30...0) 36 | .chartYAxis { 37 | AxisMarks { 38 | value in 39 | AxisGridLine() 40 | AxisTick() 41 | AxisValueLabel { 42 | Text("\(qBittorrent.getFormatedSize(size: value.as(Int64.self)!))/s") 43 | } 44 | } 45 | } 46 | .chartXAxis { 47 | AxisMarks { 48 | value in 49 | AxisGridLine() 50 | AxisTick() 51 | AxisValueLabel { 52 | Text("\(String(abs(value.as(Int.self)!)))s") 53 | } 54 | } 55 | } 56 | }.padding(10) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /qBitControl/Components/TorrentRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentRowView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct TorrentRowView: View { 9 | let name: String 10 | let progress: Float 11 | let state: String 12 | let dlspeed: Int64 13 | let upspeed: Int64 14 | let ratio: Float 15 | 16 | let iconLeftPadding = -5.0 17 | 18 | let screenWidth = UIScreen.main.bounds.width 19 | 20 | var body: some View { 21 | VStack { 22 | HStack(alignment: .bottom) { 23 | Text(name) 24 | .lineLimit(1) 25 | Spacer() 26 | }.padding(.bottom, -1) 27 | 28 | ProgressView(value: progress) 29 | .progressViewStyle(LinearProgressViewStyle(tint: qBittorrent.getStateColor(state: state))) 30 | 31 | HStack(spacing: 3.5) { 32 | Group { 33 | Image(systemName: "\(qBittorrent.getStateIcon(state: state))") 34 | .foregroundColor(qBittorrent.getStateColor(state: state)) 35 | .font(.footnote) 36 | //Text("\(qBittorrent.getState(state: state))") 37 | .lineLimit(1) 38 | } 39 | Group { 40 | Text("\(String(format: "%.1f", progress*100))%") 41 | Text("•") 42 | } 43 | Group { 44 | Image(systemName: "arrow.down") 45 | Text("\(qBittorrent.getFormatedSize(size: dlspeed))/s") 46 | Text("•") 47 | } 48 | Group { 49 | Image(systemName: "arrow.up") 50 | Text("\(qBittorrent.getFormatedSize(size: upspeed))/s") 51 | Text("•") 52 | } 53 | Group { 54 | Image(systemName: "arrow.up.arrow.down") 55 | Text("\(String(format: "%.2f", ratio))") 56 | } 57 | Spacer() 58 | } 59 | .font(.caption) 60 | .padding(.top, 1) 61 | .lineLimit(1) 62 | } 63 | 64 | 65 | } 66 | } 67 | 68 | struct TorrentRowView_Previews: PreviewProvider { 69 | static var previews: some View { 70 | NavigationView { 71 | List { 72 | NavigationLink { 73 | //MainView() 74 | } label: { 75 | TorrentRowView(name: "Torrent name", progress: 0.789, state: "downloading", dlspeed:10000000, upspeed:1000000, ratio: 0.5) 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /qBitControl/Components/TrackerRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsTrackerRow.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct TrackerRow: View { 9 | 10 | @Binding var tracker: Tracker 11 | 12 | func getStatus(status: Int) -> String { 13 | switch status { 14 | case 0: 15 | return NSLocalizedString("Disabled", comment: "") 16 | case 1: 17 | return NSLocalizedString("Not contacted yet", comment: "") 18 | case 2: 19 | return NSLocalizedString("Working", comment: "") 20 | case 3: 21 | return NSLocalizedString("Updating", comment: "") 22 | case 4: 23 | return NSLocalizedString("Not working", comment: "") 24 | default: 25 | return NSLocalizedString("Unknown", comment: "") 26 | } 27 | } 28 | 29 | var body: some View { 30 | VStack { 31 | HStack { 32 | Text(tracker.url) 33 | Spacer() 34 | } 35 | HStack(spacing: 3) { 36 | Group { 37 | if tracker.msg == "" { 38 | Text(getStatus(status: tracker.status)) 39 | } else { 40 | Text(tracker.msg.capitalized) 41 | } 42 | } 43 | if tracker.status == 2 { 44 | Group { 45 | Text("•") 46 | Image(systemName: "square.and.arrow.up") 47 | Text("\(tracker.num_seeds)") 48 | Text("•") 49 | } 50 | Group { 51 | Image(systemName: "arrow.up.and.down") 52 | Text("\(tracker.num_leeches)") 53 | Text("•") 54 | } 55 | Group { 56 | Image(systemName: "person.2") 57 | Text("\(tracker.num_peers)") 58 | } 59 | } 60 | Spacer() 61 | }.foregroundColor(Color.gray) 62 | .lineLimit(1) 63 | .font(.footnote) 64 | } 65 | } 66 | } 67 | 68 | struct TorrentDetailsTrackerRow_Previews: PreviewProvider { 69 | static var previews: some View { 70 | List { 71 | TrackerRow(tracker: .constant(Tracker(url: "http://example.com/announce", status: 1, tier: 1, num_peers: 100, num_seeds: 100, num_leeches: 10, num_downloaded: 1000, msg: "Tracker available"))) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /qBitControl/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | CFBundleDocumentTypes 11 | 12 | 13 | CFBundleTypeName 14 | BitTorrent File 15 | CFBundleTypeRole 16 | Editor 17 | LSHandlerRank 18 | Owner 19 | LSIsAppleDefaultForType 20 | 21 | LSItemContentTypes 22 | 23 | com.qBitControl.bittorrent 24 | 25 | 26 | 27 | CFBundleURLTypes 28 | 29 | 30 | CFBundleTypeRole 31 | Viewer 32 | CFBundleURLIconFile 33 | logo1 34 | CFBundleURLName 35 | com.MikeMichael225.qBitControl 36 | CFBundleURLSchemes 37 | 38 | magnet 39 | 40 | 41 | 42 | UTExportedTypeDeclarations 43 | 44 | 45 | UTTypeConformsTo 46 | 47 | public.data 48 | public.item 49 | com.bittorrent.torrent 50 | 51 | UTTypeDescription 52 | BitTorrent File 53 | UTTypeIconFiles 54 | 55 | logo1 56 | 57 | UTTypeIdentifier 58 | com.qBitControl.bittorrent 59 | UTTypeReferenceURL 60 | https://www.bittorrent.org/beps/bep_0000.html 61 | UTTypeTagSpecification 62 | 63 | public.filename-extension 64 | 65 | torrent 66 | 67 | public.mime-type 68 | 69 | application/x-bittorrent 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /qBitControl/Models/AlertIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct AlertIdentifier: Identifiable { 7 | enum Choice { 8 | case resumeAll, pauseAll 9 | } 10 | 11 | var id: Choice 12 | } -------------------------------------------------------------------------------- /qBitControl/Models/Category.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct Category: Decodable, Hashable { 7 | let name: String 8 | let savePath: String 9 | } 10 | -------------------------------------------------------------------------------- /qBitControl/Models/File.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct File: Decodable { 7 | let index: Int // File index 8 | let name: String // File name (including relative path) 9 | let size: Int64 // File size (bytes) 10 | let progress: Float // File progress (percentage/100) 11 | let priority: Int // File priority. See possible values here below 12 | let is_seed: Bool? // True if file is seeding/complete 13 | let piece_range: [Int]// The first number is the starting piece index and the second number is the ending piece index (inclusive) 14 | let availability: Float // Percentage of file pieces currently available (percentage/100) 15 | 16 | /** 17 | Possible values of priority: 18 | Value Description 19 | 0 Do not download 20 | 1 Normal priority 21 | 6 High priority 22 | 7 Maximal priority 23 | */ 24 | } -------------------------------------------------------------------------------- /qBitControl/Models/GlobalTransferInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct GlobalTransferInfo: Decodable, Identifiable { 7 | let id = UUID() 8 | var fetchDate: Date = Date.now 9 | let dl_info_speed: Int64 // integer Global download rate (bytes/s) 10 | let dl_info_data: Int64 // integer Data downloaded this session (bytes) 11 | let up_info_speed: Int64 // integer Global upload rate (bytes/s) 12 | let up_info_data: Int64 // integer Data uploaded this session (bytes) 13 | let dl_rate_limit: Int64 // integer Download rate limit (bytes/s) 14 | let up_rate_limit: Int64 // integer Upload rate limit (bytes/s) 15 | let dht_nodes: Int64 // integer DHT nodes connected to 16 | let connection_status: String // string Connection status. See possible values here below 17 | 18 | enum CodingKeys: CodingKey { 19 | case dl_info_speed 20 | case dl_info_data 21 | case up_info_speed 22 | case up_info_data 23 | case dl_rate_limit 24 | case up_rate_limit 25 | case dht_nodes 26 | case connection_status 27 | } 28 | 29 | init(fetchDate: Date, dlspeed: Int64, dldata: Int64, dllimit: Int64, upspeed: Int64, updata: Int64, uplimit: Int64, dhtnodes: Int64, connection_status: String) { 30 | self.fetchDate = fetchDate 31 | self.dl_info_speed = dlspeed 32 | self.dl_info_data = dldata 33 | self.dl_rate_limit = dllimit 34 | 35 | self.up_info_speed = upspeed 36 | self.up_info_data = updata 37 | self.up_rate_limit = uplimit 38 | 39 | self.dht_nodes = dhtnodes 40 | self.connection_status = connection_status 41 | } 42 | 43 | init(from decoder: Decoder) throws { 44 | let container = try decoder.container(keyedBy: CodingKeys.self) 45 | 46 | self.dl_info_data = try container.decode(Int64.self, forKey: .dl_info_data) 47 | self.dl_info_speed = try container.decode(Int64.self, forKey: .dl_info_speed) 48 | self.dl_rate_limit = try container.decode(Int64.self, forKey: .dl_rate_limit) 49 | 50 | self.up_info_data = try container.decode(Int64.self, forKey: .up_info_data) 51 | self.up_info_speed = try container.decode(Int64.self, forKey: .up_info_speed) 52 | self.up_rate_limit = try container.decode(Int64.self, forKey: .up_rate_limit) 53 | 54 | self.dht_nodes = try container.decode(Int64.self, forKey: .dht_nodes) 55 | self.connection_status = try container.decode(String.self, forKey: .connection_status) 56 | } 57 | } -------------------------------------------------------------------------------- /qBitControl/Models/MainData.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct MainData: Decodable { 7 | let rid: Int 8 | let full_update: Bool? 9 | let server_state: PartialServerState? 10 | } -------------------------------------------------------------------------------- /qBitControl/Models/PartialServerState.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct PartialServerState: Decodable { 7 | let alltime_dl: Int64? 8 | let alltime_ul: Int64? 9 | let average_time_queue: Int? 10 | let connection_status: String? 11 | let dht_nodes: Int? 12 | let dl_info_data: Int64? 13 | let dl_info_speed: Int? 14 | let dl_rate_limit: Int? 15 | let free_space_on_disk: Int64? 16 | let global_ratio: String? 17 | let queued_io_jobs: Int? 18 | let queueing: Bool? 19 | let read_cache_hits: String? 20 | let read_cache_overload: String? 21 | let refresh_interval: Int? 22 | let total_buffers_size: Int? 23 | let total_peer_connections: Int? 24 | let total_queued_size: Int? 25 | let total_wasted_session: Int64? 26 | let up_info_data: Int64? 27 | let up_info_speed: Int? 28 | let up_rate_limit: Int? 29 | let use_alt_speed_limits: Bool? 30 | let use_subcategories: Bool? 31 | let write_cache_overload: String? 32 | } -------------------------------------------------------------------------------- /qBitControl/Models/Peer.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct Peer: Decodable { 7 | let client: String 8 | let connection: String 9 | let country: String 10 | let country_code: String 11 | let dl_speed: Int64 12 | let downloaded: Int64 13 | let files: String 14 | let flags: String 15 | let flags_desc: String 16 | let ip: String 17 | let port: Int 18 | let progress: Double 19 | let relevance: Double 20 | let up_speed: Int64 21 | let uploaded: Int64 22 | } -------------------------------------------------------------------------------- /qBitControl/Models/Peers.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct Peers: Decodable { 7 | let full_update: Bool 8 | let peers: [String: Peer] 9 | } -------------------------------------------------------------------------------- /qBitControl/Models/RSSFeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct RSSFeed: Decodable, Identifiable { 7 | var id: UUID { UUID() } 8 | let url: String? 9 | let uid: String? 10 | let isLoading: Bool? 11 | let title: String 12 | let hasError: Bool? 13 | let articles: [Article] 14 | 15 | struct Article: Decodable, Identifiable { 16 | var id: UUID { UUID() } 17 | let category: String? 18 | let title: String? 19 | let date: String? 20 | let link: String? 21 | let size: String? 22 | let torrentURL: String? 23 | let isRead: Bool? 24 | 25 | var description: String? { 26 | var components: [String] = [] 27 | if let category = self.category { components.append(category) } 28 | if let size = self.size { components.append(size) } 29 | 30 | let result = components.joined(separator: " • ") 31 | return result.isEmpty ? nil : result 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /qBitControl/Models/RSSNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class RSSNode: Decodable, Identifiable { 4 | let id = UUID() 5 | var title = "RSS" 6 | var nodes: [RSSNode] = [] 7 | var feeds: [RSSFeed] = [] 8 | 9 | weak var parent: RSSNode? 10 | 11 | func getPath() -> String { 12 | if let parent = self.parent { 13 | if parent.getPath().isEmpty { 14 | return title 15 | } 16 | return "\(parent.getPath())\\\(title)" 17 | } 18 | if title == "RSS" { return "" } 19 | return title 20 | } 21 | 22 | func getNode(path: [String]) -> RSSNode? { 23 | if path.count == 1 && path.first! == self.title { return self } 24 | 25 | var newPath = path 26 | newPath.removeFirst() 27 | 28 | for node in nodes { 29 | if newPath.first! == node.title { 30 | return node.getNode(path: newPath) 31 | } 32 | } 33 | 34 | return nil 35 | } 36 | 37 | init() { } 38 | 39 | required init(from decoder: any Decoder) throws { 40 | let decoder = try decoder.singleValueContainer() 41 | 42 | if let feedsOrNodes = try? decoder.decode([String: RSSFeedOrNode].self) { 43 | for (key, value) in feedsOrNodes { 44 | switch value { 45 | case .feed(let feed): 46 | // The feed's title is not changing after renaming in qBittorrent, the correct value is the key 47 | let newFeed = RSSFeed(url: feed.url, uid: feed.uid, isLoading: feed.isLoading, title: key, hasError: feed.hasError, articles: feed.articles) 48 | feeds.append(newFeed) 49 | case .node(let node): 50 | node.title = key 51 | node.parent = self 52 | nodes.append(node) 53 | case .empty: 54 | continue 55 | } 56 | } 57 | } 58 | 59 | nodes.sort(by: { node1, node2 in node1.title < node2.title }) 60 | feeds.sort(by: { feed1, feed2 in feed1.title < feed2.title }) 61 | } 62 | 63 | enum RSSFeedOrNode: Decodable { 64 | case feed(RSSFeed) 65 | case node(RSSNode) 66 | case empty 67 | 68 | init(from decoder: any Decoder) throws { 69 | let decoder = try decoder.singleValueContainer() 70 | 71 | if let feed = try? decoder.decode(RSSFeed.self) { 72 | self = .feed(feed) 73 | return 74 | } 75 | 76 | if let node = try? decoder.decode(RSSNode.self) { 77 | self = .node(node) 78 | return 79 | } 80 | 81 | self = .empty 82 | return 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /qBitControl/Models/SearchCategory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SearchCategory: Hashable, Decodable { 4 | let name: String 5 | let id: String 6 | } 7 | -------------------------------------------------------------------------------- /qBitControl/Models/SearchPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SearchPlugin: Hashable, Decodable { 4 | let enabled: Bool? 5 | let fullName: String? 6 | let name: String? 7 | let supportedCategories: [SearchCategory]? 8 | let url: String? 9 | let version: String? 10 | } 11 | -------------------------------------------------------------------------------- /qBitControl/Models/SearchResponse.swift: -------------------------------------------------------------------------------- 1 | struct SearchResponse: Decodable { 2 | let results: [SearchResult] 3 | let status: String 4 | let total: Int 5 | } 6 | -------------------------------------------------------------------------------- /qBitControl/Models/SearchResult.swift: -------------------------------------------------------------------------------- 1 | import Foundation // Required for UUID 2 | 3 | struct SearchResult: Decodable, Identifiable { 4 | var id = UUID() 5 | 6 | let descrLink: String? 7 | let engineName: String? 8 | let fileName: String? 9 | let fileSize: Int64? 10 | let fileUrl: String? 11 | let nbLeechers: Int? 12 | let nbSeeders: Int? 13 | let pubDate: Int? 14 | let siteUrl: String? 15 | 16 | private enum CodingKeys: String, CodingKey { 17 | case descrLink 18 | case engineName 19 | case fileName 20 | case fileSize 21 | case fileUrl 22 | case nbLeechers 23 | case nbSeeders 24 | case pubDate 25 | case siteUrl 26 | } 27 | 28 | init(from decoder: Decoder) throws { 29 | let container = try decoder.container(keyedBy: CodingKeys.self) 30 | 31 | descrLink = try container.decodeIfPresent(String.self, forKey: .descrLink) 32 | engineName = try container.decodeIfPresent(String.self, forKey: .engineName) 33 | fileName = try container.decodeIfPresent(String.self, forKey: .fileName) 34 | fileSize = try container.decodeIfPresent(Int64.self, forKey: .fileSize) 35 | fileUrl = try container.decodeIfPresent(String.self, forKey: .fileUrl) 36 | nbLeechers = try container.decodeIfPresent(Int.self, forKey: .nbLeechers) 37 | nbSeeders = try container.decodeIfPresent(Int.self, forKey: .nbSeeders) 38 | pubDate = try container.decodeIfPresent(Int.self, forKey: .pubDate) 39 | siteUrl = try container.decodeIfPresent(String.self, forKey: .siteUrl) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /qBitControl/Models/SearchSortOptions.swift: -------------------------------------------------------------------------------- 1 | enum SearchSortOptions: String, CaseIterable { 2 | case name = "name" 3 | case size = "size" 4 | case seeders = "seeders" 5 | case leechers = "leechers" 6 | } 7 | -------------------------------------------------------------------------------- /qBitControl/Models/SearchStartResult.swift: -------------------------------------------------------------------------------- 1 | struct SearchStartResult: Decodable { 2 | let id: Int 3 | } 4 | -------------------------------------------------------------------------------- /qBitControl/Models/Server.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct Server: Codable, Identifiable { 7 | var id: String = UUID().uuidString 8 | let name: String 9 | let url: String 10 | let username: String 11 | let password: String 12 | } -------------------------------------------------------------------------------- /qBitControl/Models/ServerAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct ServerAction { 7 | let name: String 8 | let action: () -> Void 9 | } -------------------------------------------------------------------------------- /qBitControl/Models/ServerState.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct ServerState: Decodable { 7 | var alltime_dl: Int64 8 | var alltime_ul: Int64 9 | var average_time_queue: Int 10 | var connection_status: String 11 | var dht_nodes: Int 12 | var dl_info_data: Int64 13 | var dl_info_speed: Int 14 | var dl_rate_limit: Int 15 | var free_space_on_disk: Int64 16 | var global_ratio: String 17 | var queued_io_jobs: Int 18 | var queueing: Bool 19 | var read_cache_hits: String 20 | var read_cache_overload: String 21 | var refresh_interval: Int 22 | var total_buffers_size: Int 23 | var total_peer_connections: Int 24 | var total_queued_size: Int 25 | var total_wasted_session: Int64 26 | var up_info_data: Int64 27 | var up_info_speed: Int 28 | var up_rate_limit: Int 29 | var use_alt_speed_limits: Bool 30 | var use_subcategories: Bool 31 | var write_cache_overload: String 32 | 33 | // Initializer that attempts to create ServerState from PartialServerState 34 | init?(from partial: PartialServerState) { 35 | guard 36 | let alltime_dl = partial.alltime_dl, 37 | let alltime_ul = partial.alltime_ul, 38 | let average_time_queue = partial.average_time_queue, 39 | let connection_status = partial.connection_status, 40 | let dht_nodes = partial.dht_nodes, 41 | let dl_info_data = partial.dl_info_data, 42 | let dl_info_speed = partial.dl_info_speed, 43 | let dl_rate_limit = partial.dl_rate_limit, 44 | let free_space_on_disk = partial.free_space_on_disk, 45 | let global_ratio = partial.global_ratio, 46 | let queued_io_jobs = partial.queued_io_jobs, 47 | let queueing = partial.queueing, 48 | let read_cache_hits = partial.read_cache_hits, 49 | let read_cache_overload = partial.read_cache_overload, 50 | let refresh_interval = partial.refresh_interval, 51 | let total_buffers_size = partial.total_buffers_size, 52 | let total_peer_connections = partial.total_peer_connections, 53 | let total_queued_size = partial.total_queued_size, 54 | let total_wasted_session = partial.total_wasted_session, 55 | let up_info_data = partial.up_info_data, 56 | let up_info_speed = partial.up_info_speed, 57 | let up_rate_limit = partial.up_rate_limit, 58 | let use_alt_speed_limits = partial.use_alt_speed_limits, 59 | let use_subcategories = partial.use_subcategories, 60 | let write_cache_overload = partial.write_cache_overload 61 | else { 62 | return nil 63 | } 64 | 65 | self.alltime_dl = alltime_dl 66 | self.alltime_ul = alltime_ul 67 | self.average_time_queue = average_time_queue 68 | self.connection_status = connection_status 69 | self.dht_nodes = dht_nodes 70 | self.dl_info_data = dl_info_data 71 | self.dl_info_speed = dl_info_speed 72 | self.dl_rate_limit = dl_rate_limit 73 | self.free_space_on_disk = free_space_on_disk 74 | self.global_ratio = global_ratio 75 | self.queued_io_jobs = queued_io_jobs 76 | self.queueing = queueing 77 | self.read_cache_hits = read_cache_hits 78 | self.read_cache_overload = read_cache_overload 79 | self.refresh_interval = refresh_interval 80 | self.total_buffers_size = total_buffers_size 81 | self.total_peer_connections = total_peer_connections 82 | self.total_queued_size = total_queued_size 83 | self.total_wasted_session = total_wasted_session 84 | self.up_info_data = up_info_data 85 | self.up_info_speed = up_info_speed 86 | self.up_rate_limit = up_rate_limit 87 | self.use_alt_speed_limits = use_alt_speed_limits 88 | self.use_subcategories = use_subcategories 89 | self.write_cache_overload = write_cache_overload 90 | } 91 | 92 | mutating func update(from partial: PartialServerState) { 93 | if let alltime_dl = partial.alltime_dl { self.alltime_dl = alltime_dl } 94 | if let alltime_ul = partial.alltime_ul { self.alltime_ul = alltime_ul } 95 | if let average_time_queue = partial.average_time_queue { self.average_time_queue = average_time_queue } 96 | if let connection_status = partial.connection_status { self.connection_status = connection_status } 97 | if let dht_nodes = partial.dht_nodes { self.dht_nodes = dht_nodes } 98 | if let dl_info_data = partial.dl_info_data { self.dl_info_data = dl_info_data } 99 | if let dl_info_speed = partial.dl_info_speed { self.dl_info_speed = dl_info_speed } 100 | if let dl_rate_limit = partial.dl_rate_limit { self.dl_rate_limit = dl_rate_limit } 101 | if let free_space_on_disk = partial.free_space_on_disk { self.free_space_on_disk = free_space_on_disk } 102 | if let global_ratio = partial.global_ratio { self.global_ratio = global_ratio } 103 | if let queued_io_jobs = partial.queued_io_jobs { self.queued_io_jobs = queued_io_jobs } 104 | if let queueing = partial.queueing { self.queueing = queueing } 105 | if let read_cache_hits = partial.read_cache_hits { self.read_cache_hits = read_cache_hits } 106 | if let read_cache_overload = partial.read_cache_overload { self.read_cache_overload = read_cache_overload } 107 | if let refresh_interval = partial.refresh_interval { self.refresh_interval = refresh_interval } 108 | if let total_buffers_size = partial.total_buffers_size { self.total_buffers_size = total_buffers_size } 109 | if let total_peer_connections = partial.total_peer_connections { self.total_peer_connections = total_peer_connections } 110 | if let total_queued_size = partial.total_queued_size { self.total_queued_size = total_queued_size } 111 | if let total_wasted_session = partial.total_wasted_session { self.total_wasted_session = total_wasted_session } 112 | if let up_info_data = partial.up_info_data { self.up_info_data = up_info_data } 113 | if let up_info_speed = partial.up_info_speed { self.up_info_speed = up_info_speed } 114 | if let up_rate_limit = partial.up_rate_limit { self.up_rate_limit = up_rate_limit } 115 | if let use_alt_speed_limits = partial.use_alt_speed_limits { self.use_alt_speed_limits = use_alt_speed_limits } 116 | if let use_subcategories = partial.use_subcategories { self.use_subcategories = use_subcategories } 117 | if let write_cache_overload = partial.write_cache_overload { self.write_cache_overload = write_cache_overload } 118 | } 119 | } -------------------------------------------------------------------------------- /qBitControl/Models/SheetIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct SheetIdentifier: Identifiable { 7 | enum Choice { 8 | case showAbout 9 | } 10 | 11 | var id: Choice 12 | } -------------------------------------------------------------------------------- /qBitControl/Models/Torrent.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct Torrent: Decodable, Hashable { 7 | let added_on: Int 8 | let amount_left: Int 9 | let auto_tmm: Bool 10 | let availability: Float 11 | let category: String 12 | let completed: Int 13 | let completion_on: Int 14 | let content_path: String? 15 | let dl_limit: Int64 16 | let dlspeed: Int64 17 | let downloaded: Int64 18 | let downloaded_session: Int64 19 | let eta: Int 20 | let f_l_piece_prio: Bool 21 | let force_start: Bool 22 | let hash: String 23 | let last_activity: Int 24 | let magnet_uri: String 25 | let max_ratio: Float 26 | let max_seeding_time: Int 27 | let name: String 28 | let num_complete: Int 29 | let num_incomplete: Int 30 | let num_leechs: Int 31 | let num_seeds: Int 32 | let priority: Int 33 | let progress: Float 34 | let ratio: Float 35 | let ratio_limit: Float 36 | let save_path: String 37 | let seeding_time: Int? 38 | let seeding_time_limit: Int 39 | let seen_complete: Int 40 | let seq_dl: Bool 41 | let size: Int64 42 | var state: String 43 | let super_seeding: Bool 44 | let tags: String 45 | let time_active: Int 46 | let total_size: Int64 47 | let tracker: String 48 | let up_limit: Int64 49 | let uploaded: Int64 50 | let uploaded_session: Int64 51 | let upspeed: Int64 52 | } -------------------------------------------------------------------------------- /qBitControl/Models/Tracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct Tracker: Decodable, Hashable { 7 | let url: String // Tracker url 8 | let status: Int // Tracker status. See the table below for possible values 9 | let tier: Int // Tracker priority tier. Lower tier trackers are tried before higher tiers. Tier numbers are valid when >= 0, < 0 is used as placeholder when tier does not exist for special entries (such as DHT). 10 | let num_peers: Int // Number of peers for current torrent, as reported by the tracker 11 | let num_seeds: Int // Number of seeds for current torrent, asreported by the tracker 12 | let num_leeches: Int // Number of leeches for current torrent, as reported by the tracker 13 | let num_downloaded: Int // Number of completed downlods for current torrent, as reported by the tracker 14 | let msg: String // Tracker message (there is no way of knowing what this message is - it's up to tracker admins) 15 | 16 | 17 | /** 18 | Possible values of status: 19 | Value Description 20 | 0 Tracker is disabled (used for DHT, PeX, and LSD) 21 | 1 Tracker has not been contacted yet 22 | 2 Tracker has been contacted and is working 23 | 3 Tracker is updating 24 | 4 Tracker has been contacted, but it is not working (or doesn't send proper replies) 25 | */ 26 | } -------------------------------------------------------------------------------- /qBitControl/Models/TransferInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct TransferInfo: Identifiable { 7 | var id = UUID() 8 | let fetchDate: Date 9 | let info_speed: Int 10 | } -------------------------------------------------------------------------------- /qBitControl/Models/Version.swift: -------------------------------------------------------------------------------- 1 | struct Version { 2 | let major: Int 3 | let minor: Int 4 | let patch: Int 5 | } 6 | 7 | -------------------------------------------------------------------------------- /qBitControl/Models/qBitPreferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | 4 | import Foundation 5 | 6 | struct qBitPreferences: Decodable { 7 | let locale: String? 8 | let create_subfolder_enabled: Bool? 9 | let start_paused_enabled: Bool? 10 | let auto_delete_mode: Int? 11 | let preallocate_all: Bool? 12 | let incomplete_files_ext: Bool? 13 | let auto_tmm_enabled: Bool? 14 | let torrent_changed_tmm_enabled: Bool? 15 | let save_path_changed_tmm_enabled: Bool? 16 | let category_changed_tmm_enabled: Bool? 17 | let save_path: String? 18 | let temp_path_enabled: Bool? 19 | let temp_path: String? 20 | //let scan_dirs: [Any] 21 | let export_dir: String? 22 | let export_dir_fin: String? 23 | let mail_notification_enabled: Bool? 24 | let mail_notification_sender: String? 25 | let mail_notification_email: String? 26 | let mail_notification_smtp: String? 27 | let mail_notification_ssl_enabled: Bool? 28 | let mail_notification_auth_enabled: Bool? 29 | let mail_notification_username: String? 30 | let mail_notification_password: String? 31 | let autorun_enabled: Bool? 32 | let autorun_program: String? 33 | let queueing_enabled: Bool? 34 | let max_active_downloads: Int? 35 | let max_active_torrents: Int? 36 | let max_active_uploads: Int? 37 | let dont_count_slow_torrents: Bool? 38 | let slow_torrent_dl_rate_threshold: Int? 39 | let slow_torrent_ul_rate_threshold: Int? 40 | let slow_torrent_inactive_timer: Int? 41 | let max_ratio_enabled: Bool? 42 | let max_ratio: Float? 43 | let max_ratio_act: Int? 44 | let listen_port: Int? 45 | let upnp: Bool? 46 | let random_port: Bool? 47 | let dl_limit: Int? 48 | let up_limit: Int? 49 | let max_connec: Int? 50 | let max_connec_per_torrent: Int? 51 | let max_uploads: Int? 52 | let max_uploads_per_torrent: Int? 53 | let stop_tracker_timeout: Int? 54 | let enable_piece_extent_affinity: Bool? 55 | let bittorrent_protocol: Int? 56 | let limit_utp_rate: Bool? 57 | let limit_tcp_overhead: Bool? 58 | let limit_lan_peers: Bool? 59 | let alt_dl_limit: Int? 60 | let alt_up_limit: Int? 61 | let scheduler_enabled: Bool? 62 | let schedule_from_hour: Int? 63 | let schedule_from_min: Int? 64 | let schedule_to_hour: Int? 65 | let schedule_to_min: Int? 66 | let scheduler_days: Int? 67 | let dht: Bool? 68 | let pex: Bool? 69 | let lsd: Bool? 70 | let encryption: Int? 71 | let anonymous_mode: Bool? 72 | //let proxy_type: String? 73 | let proxy_ip: String? 74 | let proxy_port: Int? 75 | let proxy_peer_connections: Bool? 76 | let proxy_auth_enabled: Bool? 77 | let proxy_username: String? 78 | let proxy_password: String? 79 | let proxy_torrents_only: Bool? 80 | let ip_filter_enabled: Bool? 81 | let ip_filter_path: String? 82 | let ip_filter_trackers: Bool? 83 | let web_ui_domain_list: String? 84 | let web_ui_address: String? 85 | let web_ui_port: Int? 86 | let web_ui_upnp: Bool? 87 | let web_ui_username: String? 88 | let web_ui_password: String? 89 | let web_ui_csrf_protection_enabled: Bool? 90 | let web_ui_clickjacking_protection_enabled: Bool? 91 | let web_ui_secure_cookie_enabled: Bool? 92 | let web_ui_max_auth_fail_count: Int? 93 | let web_ui_ban_duration: Int? 94 | let web_ui_session_timeout: Int? 95 | let web_ui_host_header_validation_enabled: Bool? 96 | let bypass_local_auth: Bool? 97 | let bypass_auth_subnet_whitelist_enabled: Bool? 98 | let bypass_auth_subnet_whitelist: String? 99 | let alternative_webui_enabled: Bool? 100 | let alternative_webui_path: String? 101 | let use_https: Bool? 102 | let ssl_key: String? 103 | let ssl_cert: String? 104 | let web_ui_https_key_path: String? 105 | let web_ui_https_cert_path: String? 106 | let dyndns_enabled: Bool? 107 | let dyndns_service: Int? 108 | let dyndns_username: String? 109 | let dyndns_password: String? 110 | let dyndns_domain: String? 111 | let rss_refresh_interval: Int? 112 | let rss_max_articles_per_feed: Int? 113 | let rss_processing_enabled: Bool? 114 | let rss_auto_downloading_enabled: Bool? 115 | let rss_download_repack_proper_episodes: Bool? 116 | let rss_smart_episode_filters: String? 117 | let add_trackers_enabled: Bool? 118 | let add_trackers: String? 119 | let web_ui_use_custom_http_headers_enabled: Bool? 120 | let web_ui_custom_http_headers: String? 121 | let max_seeding_time_enabled: Bool? 122 | let max_seeding_time: Int? 123 | let announce_ip: String? 124 | let announce_to_all_tiers: Bool? 125 | let announce_to_all_trackers: Bool? 126 | let async_io_threads: Int? 127 | let banned_IPs: String? 128 | let checking_memory_use: Int? 129 | let current_interface_address: String? 130 | let current_network_interface: String? 131 | let disk_cache: Int? 132 | let disk_cache_ttl: Int? 133 | let embedded_tracker_port: Int? 134 | let enable_coalesce_read_write: Bool? 135 | let enable_embedded_tracker: Bool? 136 | let enable_multi_connections_from_same_ip: Bool? 137 | let enable_os_cache: Bool? 138 | let enable_upload_suggestions: Bool? 139 | let file_pool_size: Int? 140 | let outgoing_ports_max: Int? 141 | let outgoing_ports_min: Int? 142 | let recheck_completed_torrents: Bool? 143 | let resolve_peer_countries: Bool? 144 | let save_resume_data_interval: Int? 145 | let send_buffer_low_watermark: Int? 146 | let send_buffer_watermark: Int? 147 | let send_buffer_watermark_factor: Int? 148 | let socket_backlog_size: Int? 149 | let upload_choking_algorithm: Int? 150 | let upload_slots_behavior: Int? 151 | let upnp_lease_duration: Int? 152 | let utp_tcp_mixed_mode: Int? 153 | } -------------------------------------------------------------------------------- /qBitControl/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /qBitControl/Toolbars/TorrentListToolbar/TorrentListDefaultToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | 5 | struct TorrentListDefaultToolbar: ToolbarContent { 6 | @Binding public var torrents: [Torrent] 7 | 8 | @Binding public var category: String 9 | 10 | @Binding public var isSelectionMode: Bool 11 | @Binding public var isFilterView: Bool 12 | 13 | @State private var alertIdentifier: AlertIdentifier? 14 | @State private var sheetIdentifier: SheetIdentifier? 15 | @State private var isAlertClearCompleted: Bool = false 16 | 17 | private var categoryName: String { 18 | if(category == "All") { 19 | return NSLocalizedString("All", comment: "Pause All/Resume All") 20 | } 21 | return category.capitalized 22 | } 23 | 24 | var body: some ToolbarContent { 25 | ToolbarItem(placement: .topBarLeading) { 26 | Menu { 27 | Section { 28 | Button { 29 | isSelectionMode = true 30 | } label: { 31 | Image(systemName: "checkmark.circle") 32 | Text("Select") 33 | } 34 | } 35 | 36 | if(category != "None") { 37 | Section { 38 | Button { 39 | let torrentsInCategory = torrents.filter { 40 | torrent in 41 | return torrent.category == category 42 | } 43 | 44 | qBittorrent.resumeTorrents(hashes: torrentsInCategory.compactMap { torrent in torrent.hash }) 45 | } label: { 46 | Image(systemName: "play") 47 | .rotationEffect(.degrees(180)) 48 | Text(NSLocalizedString("Resume", comment: "") + " " + self.categoryName) 49 | } 50 | 51 | Button { 52 | let torrentsInCategory = torrents.filter { 53 | torrent in 54 | return torrent.category == category 55 | } 56 | 57 | qBittorrent.pauseTorrents(hashes: torrentsInCategory.compactMap { torrent in torrent.hash }) 58 | } label: { 59 | Image(systemName: "pause") 60 | .rotationEffect(.degrees(180)) 61 | Text(NSLocalizedString("Pause", comment: "") + " " + self.categoryName) 62 | } 63 | } 64 | } 65 | 66 | Section { 67 | Button { 68 | alertIdentifier = AlertIdentifier(id: .resumeAll) 69 | } label: { 70 | Image(systemName: "play") 71 | .rotationEffect(.degrees(180)) 72 | Text("Resume All Tasks") 73 | } 74 | 75 | Button { 76 | alertIdentifier = AlertIdentifier(id: .pauseAll) 77 | } label: { 78 | Image(systemName: "pause") 79 | .rotationEffect(.degrees(180)) 80 | Text("Pause All Tasks") 81 | } 82 | } 83 | 84 | Section { 85 | Button(role: .destructive) { 86 | isAlertClearCompleted = true 87 | } label: { 88 | Image(systemName: "trash") 89 | .rotationEffect(.degrees(180)) 90 | Text("Clear Completed") 91 | } 92 | } 93 | 94 | Section { 95 | Button { 96 | sheetIdentifier = SheetIdentifier(id: .showAbout) 97 | } label: { 98 | Image(systemName: "info.circle") 99 | Text("About") 100 | } 101 | } 102 | } label: { 103 | Image(systemName: "ellipsis.circle") 104 | }.alert(item: $alertIdentifier) { alert in 105 | switch(alert.id) { 106 | case .resumeAll: 107 | return Alert(title: Text("Confirm Resume All"), message: Text("Are you sure you want to resume all tasks?"), primaryButton: .default(Text("Resume")) { 108 | qBittorrent.resumeAllTorrents() 109 | }, secondaryButton: .cancel()) 110 | case .pauseAll: 111 | return Alert(title: Text("Confirm Pause All"), message: Text("Are you sure you want to pause all tasks?"), primaryButton: .default(Text("Pause")) { 112 | qBittorrent.pauseAllTorrents() 113 | }, secondaryButton: .cancel()) 114 | } 115 | }.alert("Confirm Deletion", isPresented: $isAlertClearCompleted, actions: { 116 | Button("Delete Completed Tasks", role: .destructive) { 117 | let completedTorrents = torrents.filter {torrent in torrent.progress == 1} 118 | let completedHashes = completedTorrents.compactMap {torrent in torrent.hash} 119 | 120 | qBittorrent.deleteTorrents(hashes: completedHashes) 121 | } 122 | Button("Delete Completed Tasks with Files", role: .destructive) { 123 | let completedTorrents = torrents.filter {torrent in torrent.progress == 1} 124 | let completedHashes = completedTorrents.compactMap {torrent in torrent.hash} 125 | 126 | 127 | qBittorrent.deleteTorrents(hashes: completedHashes, deleteFiles: true) 128 | } 129 | }) 130 | .sheet(item: $sheetIdentifier) { 131 | sheet in 132 | switch sheet.id { 133 | case .showAbout: 134 | return AboutView() 135 | } 136 | } 137 | } 138 | 139 | ToolbarItem(placement: .topBarTrailing) { 140 | Button { 141 | isFilterView.toggle() 142 | } label: { 143 | Image(systemName: "line.3.horizontal.decrease.circle") 144 | } 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /qBitControl/Toolbars/TorrentListToolbar/TorrentListSelectionToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | 5 | struct TorrentListSelectionToolbar: ToolbarContent { 6 | @Binding public var torrents: [Torrent] 7 | 8 | @Binding public var isSelectionMode: Bool 9 | @State private var isAlertDeleteSelected: Bool = false 10 | 11 | @Binding public var selectedTorrents: Set 12 | 13 | var body: some ToolbarContent { 14 | ToolbarItem(placement: .topBarLeading) { 15 | if(selectedTorrents.count == torrents.count) { 16 | Button { 17 | selectedTorrents.removeAll() 18 | } label: { 19 | Text("Deselect All") 20 | } 21 | } else { 22 | Button { 23 | torrents.forEach { 24 | torrent in 25 | selectedTorrents.insert(torrent) 26 | } 27 | } label: { 28 | Text("Select All") 29 | } 30 | } 31 | } 32 | 33 | ToolbarItem(placement: .topBarTrailing) { 34 | Button { 35 | isSelectionMode = false 36 | 37 | // do something 38 | 39 | selectedTorrents.removeAll() 40 | } label: { 41 | Text("Done") 42 | .fontWeight(.bold) 43 | } 44 | } 45 | 46 | if(selectedTorrents.count > 0) { 47 | ToolbarItemGroup(placement: .bottomBar) { 48 | HStack { 49 | Button { 50 | let selectedHashes = selectedTorrents.compactMap { 51 | torrent in 52 | torrent.hash 53 | } 54 | 55 | qBittorrent.resumeTorrents(hashes: selectedHashes) 56 | isSelectionMode = false 57 | selectedTorrents.removeAll() 58 | } label: { 59 | Image(systemName: "play.fill") 60 | } 61 | 62 | Spacer() 63 | 64 | Button { 65 | let selectedHashes = selectedTorrents.compactMap { 66 | torrent in 67 | torrent.hash 68 | } 69 | 70 | qBittorrent.pauseTorrents(hashes: selectedHashes) 71 | isSelectionMode = false 72 | selectedTorrents.removeAll() 73 | } label: { 74 | Image(systemName: "pause.fill") 75 | } 76 | 77 | Spacer() 78 | 79 | Button { 80 | let selectedHashes = selectedTorrents.compactMap { 81 | torrent in 82 | torrent.hash 83 | } 84 | 85 | qBittorrent.recheckTorrents(hashes: selectedHashes) 86 | isSelectionMode = false 87 | selectedTorrents.removeAll() 88 | } label: { 89 | Image(systemName: "magnifyingglass") 90 | } 91 | 92 | Spacer() 93 | 94 | Button { 95 | let selectedHashes = selectedTorrents.compactMap { 96 | torrent in 97 | torrent.hash 98 | } 99 | 100 | qBittorrent.reannounceTorrents(hashes: selectedHashes) 101 | isSelectionMode = false 102 | selectedTorrents.removeAll() 103 | } label: { 104 | Image(systemName: "circle.dashed") 105 | } 106 | 107 | Spacer() 108 | 109 | Button { 110 | isAlertDeleteSelected = true 111 | } label: { 112 | Image(systemName: "trash.fill").foregroundStyle(Color(.red)) 113 | } 114 | }.alert("Confirm Deletion", isPresented: $isAlertDeleteSelected, actions: { 115 | Button("Delete Selected Tasks", role: .destructive) { 116 | let selectedHashes = selectedTorrents.compactMap { 117 | torrent in 118 | torrent.hash 119 | } 120 | 121 | qBittorrent.deleteTorrents(hashes: selectedHashes) 122 | 123 | isSelectionMode = false 124 | selectedTorrents.removeAll() 125 | } 126 | Button("Delete Selected Tasks with Files", role: .destructive) { 127 | let selectedHashes = selectedTorrents.compactMap { 128 | torrent in 129 | torrent.hash 130 | } 131 | 132 | qBittorrent.deleteTorrents(hashes: selectedHashes, deleteFiles: true) 133 | 134 | isSelectionMode = false 135 | selectedTorrents.removeAll() 136 | } 137 | }) 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /qBitControl/Toolbars/TorrentListToolbar/TorrentListToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | 5 | struct TorrentListToolbar: ToolbarContent { 6 | @Binding public var torrents: [Torrent] 7 | 8 | @Binding public var category: String 9 | 10 | @Binding public var isSelectionMode: Bool 11 | @Binding public var isFilterView: Bool 12 | 13 | @Binding public var selectedTorrents: Set 14 | 15 | var body: some ToolbarContent { 16 | if(!isSelectionMode) { 17 | TorrentListDefaultToolbar(torrents: $torrents, category: $category, isSelectionMode: $isSelectionMode, isFilterView: $isFilterView) 18 | } else { 19 | TorrentListSelectionToolbar(torrents: $torrents, isSelectionMode: $isSelectionMode, selectedTorrents: $selectedTorrents) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /qBitControl/TorrentView/ListElementView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsListElementView.swift 3 | // TorrentAttempt 4 | // 5 | // Created by Michał Grzegoszczyk on 26/10/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ListElementView: View { 11 | @Binding var label: String 12 | @Binding var value: String 13 | 14 | var body: some View { 15 | HStack { 16 | Text("\(label)") 17 | Spacer() 18 | Text("\(value)") 19 | .foregroundColor(Color.gray) 20 | .lineLimit(1) 21 | } 22 | } 23 | } 24 | 25 | 26 | struct TorrentDetailsListElementPreview: PreviewProvider { 27 | static var previews: some View { 28 | ListElementView(label: .constant("Label"), value: .constant("Value")) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/MainViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | class MainViewModel: ObservableObject { 5 | @ObservedObject var serversHelper = ServersHelper.shared 6 | 7 | func reconnectIfNeeded(on scenePhase: ScenePhase) { 8 | if scenePhase == .active && serversHelper.isLoggedIn { 9 | if let activeServerId = serversHelper.activeServerId { 10 | if let activeServer = serversHelper.getServer(id: activeServerId) { 11 | serversHelper.connect(server: activeServer) 12 | } 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/RSSView/RSSNodeViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class RSSNodeViewModel: ObservableObject { 4 | static public let shared = RSSNodeViewModel() 5 | 6 | @Published public var rssRootNode: RSSNode = .init() 7 | private var timer: Timer? 8 | 9 | init() { 10 | self.getRssRootNode() 11 | self.startTimer() 12 | } 13 | 14 | deinit { 15 | self.stopTimer() 16 | } 17 | 18 | func startTimer() { timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true, block: { _ in self.getRssRootNode() }) } 19 | func stopTimer() { timer?.invalidate() } 20 | 21 | func getRssRootNode() { 22 | qBittorrent.getRSSFeeds(completionHandler: { RSSNode in 23 | DispatchQueue.main.async { 24 | self.rssRootNode = RSSNode 25 | } 26 | }) 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/RSSView/RSSViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class RSSViewModel: ObservableObject { 4 | @Published public var RSSNode: RSSNode = .init() 5 | @Published public var updateID: UUID = UUID() 6 | 7 | init() { 8 | self.getRSSFeed() 9 | } 10 | 11 | func getRSSFeed() { 12 | qBittorrent.getRSSFeeds(withDate: true, completionHandler: { RSSNode in 13 | DispatchQueue.main.async { 14 | self.RSSNode = RSSNode 15 | self.updateID = UUID() 16 | } 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/SearchView/SearchViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class SearchViewModel: ObservableObject { 4 | @Published var query: String = "" 5 | @Published var category: SearchCategory = SearchCategory(name: "All categories", id: "all") 6 | @Published var sortBy: SearchSortOptions = .seeders 7 | @Published var isDescending: Bool = true 8 | 9 | @Published var tappedResult: SearchResult? 10 | 11 | @Published var categories: Set = [] 12 | var categoriesArray: [SearchCategory] { 13 | Array(self.categories).sorted { $0.name < $1.name } 14 | } 15 | 16 | 17 | @Published var searchId: Int? 18 | 19 | @Published var isFilterSheet: Bool = false 20 | @Published var isTorrentAddSheet: Bool = false 21 | 22 | @Published var latestResponse: SearchResponse? 23 | 24 | init() { 25 | self.loadFilters() 26 | self.fetchCategories() 27 | } 28 | 29 | var latestResults: [SearchResult] { 30 | if let latestResponse = self.latestResponse { 31 | var results = latestResponse.results 32 | results = results.sorted(by: sorter) 33 | results = isDescending ? results.reversed() : results 34 | return results 35 | } 36 | 37 | return [] 38 | } 39 | 40 | var lastestTotal: Int { 41 | self.latestResponse?.total ?? 0 42 | } 43 | 44 | var isResponse: Bool { 45 | self.lastestTotal > 0 46 | } 47 | 48 | var isRunning: Bool { 49 | searchId != nil 50 | } 51 | 52 | private var timer: Timer? 53 | 54 | func startSearch() { 55 | if(isRunning) { return } 56 | if(query.isEmpty) { return } 57 | 58 | qBittorrent.getSearchStart(pattern: self.query, category: self.category.id, completionHandler: { result in 59 | DispatchQueue.main.async { 60 | self.searchId = result.id 61 | 62 | self.timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { timer in 63 | self.monitorSearchResults() 64 | } 65 | } 66 | }) 67 | } 68 | 69 | func endSearch() { 70 | DispatchQueue.main.async { 71 | self.searchId = nil 72 | } 73 | } 74 | 75 | private func monitorSearchResults() { 76 | self.validateTimer() 77 | 78 | if let searchId = self.searchId { 79 | qBittorrent.getSearchResults(id: searchId, completionHandler: { response in 80 | DispatchQueue.main.async { 81 | self.latestResponse = response 82 | } 83 | 84 | if(response.status == "Stopped") { 85 | self.endSearch() 86 | } 87 | }) 88 | } 89 | } 90 | 91 | private func validateTimer() { 92 | if(searchId == nil) { 93 | self.timer?.invalidate() 94 | } 95 | } 96 | 97 | private func sorter(res1: SearchResult, res2: SearchResult) -> Bool { 98 | switch(sortBy) { 99 | case .name: 100 | return self.compareValues(res1.fileName, res2.fileName) 101 | case .size: 102 | return self.compareValues(res1.fileSize, res2.fileSize) 103 | case .seeders: 104 | return self.compareValues(res1.nbSeeders, res2.nbSeeders) 105 | case .leechers: 106 | return self.compareValues(res1.nbLeechers, res2.nbLeechers) 107 | } 108 | } 109 | 110 | private func compareValues(_ a: T?, _ b: T?) -> Bool { 111 | if let a = a, let b = b { 112 | return a < b 113 | } 114 | 115 | return false 116 | } 117 | 118 | private func prepareKey(_ name: String) -> String { 119 | return "searchViewModel-\(name)" 120 | } 121 | 122 | func saveFilters() { 123 | let defaults = UserDefaults.standard 124 | 125 | defaults.set(sortBy.rawValue, forKey: self.prepareKey("sortBy")) 126 | defaults.set(isDescending, forKey: self.prepareKey("isDescending")) 127 | print(isDescending) 128 | } 129 | 130 | private func loadFilters() { 131 | let defaults = UserDefaults.standard 132 | 133 | if let sortBy = defaults.string(forKey: self.prepareKey("sortBy")) { 134 | self.sortBy = SearchSortOptions(rawValue: sortBy) ?? self.sortBy 135 | } 136 | 137 | self.isDescending = defaults.bool(forKey: self.prepareKey("isDescending")) 138 | print(self.isDescending) 139 | } 140 | 141 | private func fetchCategories() { 142 | var categories: Set = [] 143 | 144 | qBittorrent.getSearchPlugins(completionHandler: { plugins in 145 | plugins.forEach { plugin in 146 | categories = categories.union(plugin.supportedCategories ?? []) 147 | } 148 | 149 | DispatchQueue.main.async { 150 | self.categories = categories 151 | } 152 | }) 153 | } 154 | 155 | func onRowTap(result: SearchResult) { 156 | self.tappedResult = result 157 | self.isTorrentAddSheet.toggle() 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/ServersView/ServerAddViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerAddView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | class ServerAddViewModel: ObservableObject { 9 | @ObservedObject var serversHelper = ServersHelper.shared 10 | 11 | var editServerId: String? 12 | 13 | @Published var friendlyName = "" 14 | @Published var url = "" 15 | @Published var username = "" 16 | @Published var password = "" 17 | @Published var isCheckConnection = true; 18 | 19 | @Published var isInvalidAlert = false; 20 | @Published var invalidAlertMessage = ""; 21 | 22 | @Published var isCheckingConnection = false; 23 | 24 | public var addButtonColor: Color { self.isCheckingConnection ? Color.gray : Color.blue } 25 | 26 | private var alertQueue: [String] = []; 27 | 28 | init() { } 29 | init(editServerId: String) { 30 | self.editServerId = editServerId 31 | 32 | if let server = serversHelper.getServer(id: editServerId) { 33 | friendlyName = server.name 34 | url = server.url 35 | username = server.username 36 | password = server.password 37 | } 38 | } 39 | 40 | func validateInputs() -> Bool { 41 | if(!(url.contains("https://") || url.contains("http://"))) { 42 | showAlert(message: "Include protocol in the URL - 'https://' or 'http://' depending on your setup.") 43 | return false; 44 | } 45 | 46 | return true; 47 | } 48 | 49 | func validateIsConnecting() -> Bool { 50 | if (self.isCheckingConnection) { 51 | //showAlert(message: "Adding, Please wait") 52 | return false; 53 | } 54 | 55 | return true; 56 | } 57 | 58 | func showAlert(message: String?) { 59 | if let message = message { 60 | alertQueue.append(message) 61 | } 62 | 63 | guard !isInvalidAlert, let message = alertQueue.first else { 64 | return; 65 | } 66 | 67 | alertQueue.removeFirst() 68 | invalidAlertMessage = message 69 | isInvalidAlert = true 70 | } 71 | 72 | func alertDismissed() { 73 | DispatchQueue.main.async { 74 | self.showAlert(message: nil) 75 | } 76 | } 77 | 78 | func addServer(server: Server) { 79 | serversHelper.addServer(server: server) 80 | } 81 | 82 | func addServer(dismiss: DismissAction) -> Void { 83 | if(!validateInputs()) { return; } 84 | if(!validateIsConnecting()) { return; } 85 | 86 | let server = Server(name: friendlyName, url: url, username: username, password: password) 87 | 88 | if(!isCheckConnection) { 89 | if let editServerId = self.editServerId { serversHelper.removeServer(id: editServerId) } 90 | addServer(server: server) 91 | dismiss() 92 | } 93 | 94 | self.isCheckingConnection = true 95 | 96 | serversHelper.checkConnection(server: server, result: { 97 | didConnect in 98 | DispatchQueue.main.async { 99 | if(didConnect) { 100 | if let editServerId = self.editServerId { self.serversHelper.removeServer(id: editServerId) } 101 | self.addServer(server: server) 102 | dismiss() 103 | } else { 104 | self.showAlert(message: "Can't connect to the server.") 105 | } 106 | self.isCheckingConnection = false 107 | } 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/TorrentView/TorrentAddViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum TorrentType { 4 | case magnet, file 5 | } 6 | 7 | class TorrentAddViewModel: ObservableObject { 8 | static let defaultCategory = Category(name: "Uncategorized", savePath: "") 9 | static let defaultTag = "Untagged" 10 | 11 | @Published var torrentType: TorrentType = .file 12 | public var torrentUrls: [URL] 13 | 14 | 15 | 16 | @Published var magnetURL: String = "" 17 | 18 | @Published var fileURLs: [URL] = [] 19 | @Published var fileNames: [String] = [] 20 | @Published var fileContent: [String: Data] = [:] 21 | 22 | var magnetOverride: Bool 23 | 24 | @Published var isFileImporter = false 25 | 26 | @Published var savePath = "" 27 | @Published var defaultSavePath = "" 28 | @Published var autoTmmEnabled = false 29 | 30 | @Published var cookie = "" 31 | @Published var category: Category = defaultCategory 32 | 33 | var tags: [String] { Array(selectedTags).sorted(by: <) } 34 | var tagsString: String { tags.joined(separator: ",") } 35 | @Published var selectedTags: Set = Set() 36 | 37 | @Published var skipChecking = false 38 | @Published var paused = false 39 | @Published var sequentialDownload = false 40 | 41 | @Published var showAdvancedOptions = false 42 | 43 | @Published var showLimits = false 44 | @Published var downloadLimit = "" 45 | @Published var uploadLimit = "" 46 | @Published var ratioLimit = "" 47 | @Published var seedingTimeLimit = "" 48 | 49 | @Published var isAppeared = false 50 | 51 | init(torrentUrls: [URL], magnetOverride: Bool = false) { 52 | self.torrentUrls = torrentUrls 53 | self.magnetOverride = magnetOverride 54 | } 55 | 56 | func getTag() -> String { tags.count > 1 ? "\(tags.count)" + " Tags" : (tags.first ?? "Untagged") } 57 | 58 | func checkTorrentType() -> Void { 59 | if torrentUrls.isEmpty { return } 60 | 61 | if torrentUrls.first!.absoluteString.contains("magnet") || magnetOverride { 62 | DispatchQueue.main.async { 63 | self.torrentType = .magnet 64 | self.magnetURL = self.torrentUrls.first!.absoluteString 65 | } 66 | } else { 67 | DispatchQueue.main.async { 68 | self.torrentType = .file 69 | self.fileURLs = self.torrentUrls 70 | 71 | self.handleTorrentFiles(fileURLs: self.fileURLs) 72 | } 73 | } 74 | } 75 | 76 | func handleTorrentFile(fileURL: URL) -> Void { 77 | let isRemote = fileURL.scheme == "https" || fileURL.scheme == "http" 78 | 79 | if fileURL.pathExtension != "torrent" && !isRemote { return } 80 | 81 | let fileName = fileURL.lastPathComponent 82 | 83 | DispatchQueue.main.async { 84 | self.fileNames.append(fileName) 85 | } 86 | 87 | if fileURL.startAccessingSecurityScopedResource() || isRemote { 88 | Task { 89 | do { 90 | let data = try Data(contentsOf: fileURL) 91 | DispatchQueue.main.async { 92 | self.fileContent[fileName] = data 93 | } 94 | } catch { 95 | print(error) 96 | } 97 | fileURL.stopAccessingSecurityScopedResource() 98 | } 99 | } 100 | } 101 | 102 | func handleTorrentFiles(fileURLs: [URL]) { 103 | for fileURL in fileURLs { 104 | handleTorrentFile(fileURL: fileURL) 105 | } 106 | } 107 | 108 | func handleTorrentFiles(fileURLs: Result<[URL], any Error>) { 109 | do { 110 | handleTorrentFiles(fileURLs: try fileURLs.get()) 111 | } catch { 112 | print(error) 113 | } 114 | } 115 | 116 | func addTorrent(then dismiss: () -> Void) { 117 | DispatchQueue.main.async { 118 | let category = self.category == Self.defaultCategory ? "" : self.category.name 119 | 120 | if self.torrentType == .magnet { 121 | qBittorrent.addMagnetTorrent(torrent: URLQueryItem(name: "urls", value: self.magnetURL), savePath: self.savePath, cookie: self.cookie, category: category, tags: self.tagsString, skipChecking: self.skipChecking, paused: self.paused, sequentialDownload: self.sequentialDownload, dlLimit: Int(self.downloadLimit) ?? -1, upLimit: Int(self.uploadLimit) ?? -1, ratioLimit: Float(self.ratioLimit) ?? -1.0, seedingTimeLimit: Int(self.seedingTimeLimit) ?? -1) 122 | } else { 123 | qBittorrent.addFileTorrent(torrents: self.fileContent, savePath: self.savePath, cookie: self.cookie, category: category, tags: self.tagsString, skipChecking: self.skipChecking, paused: self.paused, sequentialDownload: self.sequentialDownload, dlLimit: Int(self.downloadLimit) ?? -1, upLimit: Int(self.uploadLimit) ?? -1, ratioLimit: Float(self.ratioLimit) ?? -1.0, seedingTimeLimit: Int(self.seedingTimeLimit) ?? -1) 124 | } 125 | } 126 | dismiss() 127 | } 128 | 129 | func getSavePath() { 130 | if(!self.savePath.isEmpty) { return; } 131 | 132 | qBittorrent.getPreferences(completionHandler: { preferences in 133 | DispatchQueue.main.async { 134 | self.autoTmmEnabled = preferences.auto_tmm_enabled ?? false 135 | 136 | if !self.autoTmmEnabled { 137 | self.savePath = preferences.save_path ?? "" 138 | self.defaultSavePath = preferences.save_path ?? "" 139 | } 140 | } 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/TorrentView/TorrentDetailsViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class TorrentDetailsViewModel: ObservableObject { 4 | @Published public var torrent: Torrent 5 | 6 | @Published public var isDeleteAlert: Bool = false 7 | 8 | @Published public var isSequentialDownload: Bool = false 9 | @Published public var isFLPiecesFirst: Bool = false 10 | 11 | @Published public var state: State = .resumed 12 | 13 | private var tags: [String] { torrent.tags.split(separator: ", ").map { String($0) } } 14 | 15 | let haptics = UIImpactFeedbackGenerator(style: .medium) 16 | private var timer: Timer? 17 | 18 | init(torrent: Torrent) { 19 | self.torrent = torrent 20 | self.isSequentialDownload = torrent.seq_dl 21 | self.isFLPiecesFirst = torrent.f_l_piece_prio 22 | self.fetchState(state: torrent.state) 23 | } 24 | 25 | func setRefreshTimer() { 26 | self.timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { timer in 27 | self.getTorrent() 28 | } 29 | } 30 | 31 | func removeRefreshTimer() { 32 | timer?.invalidate() 33 | } 34 | 35 | func getTorrent() { 36 | let request = qBitRequest.prepareURLRequest(path: "/api/v2/torrents/info", queryItems: [URLQueryItem(name:"hashes", value: torrent.hash)]) 37 | 38 | qBitRequest.requestTorrentListJSON(request: request) { 39 | torrent in 40 | if let torrent = torrent.first { 41 | DispatchQueue.main.async { 42 | self.torrent = torrent 43 | self.isSequentialDownload = torrent.seq_dl 44 | self.isFLPiecesFirst = torrent.f_l_piece_prio 45 | self.fetchState(state: torrent.state) 46 | } 47 | } 48 | } 49 | } 50 | 51 | private func fetchState(state: String) { 52 | let state = qBittorrent.getState(state: state) 53 | if(state == "Paused") { self.state = .paused } 54 | else if(torrent.state.contains("forced")) { self.state = .forceStart } 55 | else { self.state = .resumed } 56 | } 57 | 58 | func getCategory() -> String { torrent.category != "" ? torrent.category : "Uncategorized" } 59 | func getTags() -> [String] { tags } 60 | func getTag() -> String { tags.count > 1 ? "\(tags.count)" + " Tags" : (tags.first ?? "Untagged") } 61 | func getAddedOn() -> String { qBittorrent.getFormatedDate(date: torrent.added_on) } 62 | func getSize() -> String { "\(qBittorrent.getFormatedSize(size: torrent.size))" } 63 | func getTotalSize() -> String { "\(qBittorrent.getFormatedSize(size: torrent.total_size))" } 64 | func getAvailability() -> String { torrent.availability < 0 ? "-" : "\(String(format: "%.1f", torrent.availability*100))%" } 65 | func getState() -> String { "\(qBittorrent.getState(state: torrent.state))" } 66 | func getProgress() -> String { "\(String(format: "%.2f", (torrent.progress*100)))%" } 67 | func getDownloadSpeed() -> String { "\(qBittorrent.getFormatedSize(size: torrent.dlspeed))/s" } 68 | func getUploadSpeed() -> String { "\(qBittorrent.getFormatedSize(size: torrent.upspeed))/s" } 69 | func getDownloaded() -> String { "\(qBittorrent.getFormatedSize(size: torrent.downloaded))" } 70 | func getUploaded() -> String { "\(qBittorrent.getFormatedSize(size: torrent.uploaded))" } 71 | func getRatio() -> String { "\(String(format:"%.2f", torrent.ratio))" } 72 | func getDownloadedSession() -> String { "\(qBittorrent.getFormatedSize(size: torrent.downloaded_session))" } 73 | func getUploadedSession() -> String { "\(qBittorrent.getFormatedSize(size: torrent.uploaded_session))" } 74 | func getMaxRatio() -> String { "\(torrent.max_ratio > -1 ? String(format:"%.2f", torrent.max_ratio) : NSLocalizedString("None", comment: "None"))" } 75 | func getDownloadLimit() -> String { "\(torrent.dl_limit > 0 ? qBittorrent.getFormatedSize(size: torrent.dl_limit)+"/s" : NSLocalizedString("None", comment: "None"))" } 76 | func getUploadLimit() -> String { "\(torrent.up_limit > 0 ? qBittorrent.getFormatedSize(size: torrent.up_limit)+"/s" : NSLocalizedString("None", comment: "None"))" } 77 | func getETA() -> String { torrent.progress < 1 ? qBittorrent.getFormattedTime(time: torrent.eta) : "-" } 78 | 79 | 80 | func isPaused() -> Bool { state == .paused } 81 | func isForceStart() -> Bool { state == .forceStart } 82 | 83 | func toggleTorrentPause() { 84 | haptics.impactOccurred() 85 | if self.isPaused() { 86 | qBittorrent.resumeTorrent(hash: torrent.hash) 87 | } else { 88 | qBittorrent.pauseTorrent(hash: torrent.hash) 89 | } 90 | getTorrent() 91 | } 92 | 93 | func toggleSequentialDownload() { 94 | qBittorrent.toggleSequentialDownload(hashes: [torrent.hash]) 95 | } 96 | 97 | func toggleFLPiecesFirst() { 98 | qBittorrent.toggleFLPiecesFirst(hashes: [torrent.hash]) 99 | } 100 | 101 | func setForceStart(value: Bool) { 102 | qBittorrent.setForceStart(hashes: [torrent.hash], value: value) 103 | } 104 | 105 | func recheckTorrent() { 106 | haptics.impactOccurred() 107 | qBittorrent.recheckTorrent(hash: torrent.hash) 108 | } 109 | 110 | func reannounceTorrent() { 111 | haptics.impactOccurred() 112 | qBittorrent.reannounceTorrent(hash: torrent.hash) 113 | } 114 | 115 | func deleteTorrent() { 116 | haptics.impactOccurred() 117 | isDeleteAlert = true 118 | } 119 | 120 | func moveToTopPriority() { 121 | haptics.impactOccurred() 122 | qBittorrent.topPriorityTorrents(hashes: [torrent.hash]) 123 | } 124 | 125 | func moveToBottomPriority() { 126 | haptics.impactOccurred() 127 | qBittorrent.bottomPriorityTorrents(hashes: [torrent.hash]) 128 | } 129 | 130 | func increasePriority() { 131 | haptics.impactOccurred() 132 | qBittorrent.increasePriorityTorrents(hashes: [torrent.hash]) 133 | } 134 | 135 | func decreasePriority() { 136 | haptics.impactOccurred() 137 | qBittorrent.decreasePriorityTorrents(hashes: [torrent.hash]) 138 | } 139 | 140 | func deleteTorrent(then dismiss: DismissAction) { 141 | qBittorrent.deleteTorrent(hash: torrent.hash) 142 | dismiss() 143 | } 144 | 145 | func deleteTorrentWithFiles(then dismiss: DismissAction) { 146 | qBittorrent.deleteTorrent(hash: torrent.hash, deleteFiles: true) 147 | dismiss() 148 | } 149 | 150 | enum State { 151 | case resumed, paused, forceStart 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/TorrentView/TorrentListHelperViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | import SwiftUI 3 | 4 | class TorrentListHelperViewModel: ObservableObject { 5 | let defaults = UserDefaults.standard 6 | 7 | @Published public var torrents: [Torrent] = [] 8 | 9 | @Published public var searchQuery: String = "" 10 | @Published public var sort: String = "name" 11 | @Published public var reverse: Bool = false 12 | @Published public var filter: String = "all" 13 | @Published public var category: String = "All" 14 | @Published public var tag: String = "All" 15 | 16 | @Published public var isTorrentAddView: Bool = false 17 | @Published public var isSelectionMode: Bool = false 18 | 19 | @Published public var selectedTorrents: Set = Set() 20 | 21 | @Published public var filteredTorrents: [Torrent] = [] 22 | 23 | @Published var scenePhase: ScenePhase = .active 24 | @Published var isDeleteAlert: Bool = false 25 | var timer: Timer? 26 | var hash = "" 27 | 28 | init() {} 29 | 30 | func getTorrents() { 31 | if(scenePhase != .active || isTorrentAddView || isSelectionMode) { return } 32 | 33 | var queryItems = [URLQueryItem(name: "sort", value: sort), URLQueryItem(name: "filter", value: filter), URLQueryItem(name: "reverse", value: String(reverse))] 34 | 35 | if category != "All" { queryItems.append(URLQueryItem(name: "category", value: category)) } 36 | if tag != "All" { queryItems.append(URLQueryItem(name: "tag", value: tag)) } 37 | 38 | let request = qBitRequest.prepareURLRequest(path: "/api/v2/torrents/info", queryItems: queryItems) 39 | 40 | qBitRequest.requestTorrentListJSON(request: request) { 41 | _torrents in 42 | 43 | DispatchQueue.main.async { 44 | if(self.sort == "priority") { self.torrents = self.getTorrentsSortedByPriority(torrents: _torrents) } 45 | else { self.torrents = _torrents } 46 | 47 | self.filteredTorrents = self.getFilteredTorrents(torrents: self.torrents) 48 | } 49 | } 50 | } 51 | 52 | func getTorrentsSortedByPriority(torrents: [Torrent]) -> [Torrent] { 53 | return torrents.sorted(by: { 54 | tor1, tor2 in 55 | 56 | if(reverse) { 57 | if(tor2.priority <= 0) { return false } 58 | if(tor1.priority < tor2.priority) { return false } 59 | return true; 60 | } else { 61 | if(tor2.priority <= 0) { return true } 62 | if(tor1.priority < tor2.priority) { return true; } 63 | return false; 64 | } 65 | }) 66 | } 67 | 68 | func getFilteredTorrents(torrents: [Torrent]) -> [Torrent] { 69 | if(searchQuery == "") { return torrents } 70 | return torrents.filter { torrent in torrent.name.lowercased().contains(searchQuery.lowercased()) } 71 | } 72 | 73 | func startTimer() { 74 | timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { 75 | timer in 76 | self.getTorrents() 77 | } 78 | } 79 | 80 | func stopTimer() { 81 | timer?.invalidate() 82 | } 83 | 84 | func getInitialTorrents() { 85 | reverse = defaults.bool(forKey: "reverse") 86 | sort = defaults.string(forKey: "sort") ?? sort 87 | filter = defaults.string(forKey: "filter") ?? filter 88 | 89 | getTorrents() 90 | 91 | startTimer() 92 | } 93 | 94 | func deleteTorrent(torrent: Torrent) { 95 | self.hash = torrent.hash 96 | isDeleteAlert.toggle() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/TorrentView/TorrentListViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class TorrentListViewModel: ObservableObject { 4 | @ObservedObject private var torrentListHelperViewModel: TorrentListHelperViewModel 5 | 6 | @Published public var isFilterSheet = false 7 | @Published public var torrentUrls: [URL] = [] 8 | 9 | init(_ torrentListHelperViewModel: TorrentListHelperViewModel) { 10 | self.torrentListHelperViewModel = torrentListHelperViewModel 11 | } 12 | 13 | func openUrl(url: URL) { 14 | if url.absoluteString.contains("file") || url.absoluteString.contains("magnet") { 15 | torrentListHelperViewModel.isTorrentAddView = true 16 | torrentUrls.append(url) 17 | } 18 | } 19 | 20 | func toggleTorrentAddView() { 21 | self.torrentListHelperViewModel.isTorrentAddView.toggle() 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /qBitControl/ViewModels/TorrentView/TrackersViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class TrackersViewModel: ObservableObject { 4 | private var torrentHash: String 5 | @Published public var trackers: [Tracker] = [] 6 | 7 | @Published public var isEditTrackerAlert: Bool = false 8 | @Published public var isAddTrackerAlert: Bool = false 9 | 10 | @Published public var origURL = "" 11 | @Published public var newURL = "" 12 | 13 | @Published private var timer: Timer? 14 | 15 | init(torrentHash: String) { 16 | self.torrentHash = torrentHash 17 | self.getTrackers() 18 | } 19 | 20 | func showEditTrackerPopover(tracker: Tracker) { 21 | isEditTrackerAlert = true 22 | origURL = tracker.url 23 | newURL = tracker.url 24 | } 25 | 26 | func editTracker() { 27 | qBittorrent.editTrackerURL(hash: torrentHash, origUrl: origURL, newURL: newURL) 28 | } 29 | 30 | func showAddTrackerPopover() { 31 | isAddTrackerAlert = true 32 | origURL = "" 33 | newURL = "" 34 | } 35 | 36 | func addTracker() { 37 | qBittorrent.addTrackerURL(hash: torrentHash, urls: newURL) 38 | } 39 | 40 | func removeTracker(tracker: Tracker) { 41 | qBittorrent.removeTracker(hash: torrentHash, url: tracker.url) 42 | } 43 | 44 | func getTrackers() { 45 | qBittorrent.getTrackers(hash: torrentHash) { 46 | trackers in 47 | DispatchQueue.main.async { 48 | self.trackers = trackers 49 | } 50 | } 51 | } 52 | 53 | func setRefreshTimer() { 54 | timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in 55 | self.getTrackers() 56 | } 57 | } 58 | 59 | func removeRefreshTimer() { 60 | timer?.invalidate() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /qBitControl/Views/MainView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MainView: View { 4 | @StateObject private var viewModel = MainViewModel() 5 | @ObservedObject var serversHelper = ServersHelper.shared 6 | @Environment(\.scenePhase) var scenePhase 7 | 8 | var body: some View { 9 | Group { 10 | if serversHelper.connectingServerId != nil && !serversHelper.isLoggedIn { 11 | Image("logo") 12 | .resizable() 13 | .scaledToFit() 14 | .frame(width: 100, height: 100) 15 | .cornerRadius(20) 16 | Text("qBitControl") 17 | .font(.largeTitle) 18 | } else if !serversHelper.isLoggedIn { 19 | ServersView() 20 | .onAppear { 21 | LocalNetworkPermissionService().triggerDialog() 22 | } 23 | .navigationTitle("qBitControl") 24 | } else { 25 | TabView { 26 | TorrentListView() 27 | .tabItem { 28 | Label("Tasks", systemImage: "square.and.arrow.down.on.square") 29 | } 30 | .onChange(of: scenePhase) { phase in 31 | viewModel.reconnectIfNeeded(on: phase) 32 | } 33 | 34 | RSSView() 35 | .tabItem { 36 | Label("RSS", systemImage: "dot.radiowaves.up.forward") 37 | } 38 | 39 | StatsView() 40 | .tabItem { 41 | Label("Stats", systemImage: "chart.line.uptrend.xyaxis") 42 | } 43 | 44 | ServersView() 45 | .tabItem { 46 | Label("Servers", systemImage: "server.rack") 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | struct MainView_Previews: PreviewProvider { 55 | static var previews: some View { 56 | MainView() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /qBitControl/Views/RSSViews/RSSArticleView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RSSArticleView: View { 4 | @State var article: RSSFeed.Article 5 | @State var isTorrentAddSheet: Bool = false 6 | 7 | var body: some View { 8 | Button { isTorrentAddSheet.toggle() } label: { 9 | VStack { 10 | HStack { Text(article.title ?? "No Title").lineLimit(2); Spacer() } 11 | if let description = article.description { 12 | HStack(spacing: 3) { 13 | Text(description) 14 | Spacer() 15 | } 16 | .foregroundColor(.secondary) 17 | .font(.footnote) 18 | .lineLimit(1) 19 | } 20 | } 21 | .foregroundColor(.primary) 22 | .sheet(isPresented: $isTorrentAddSheet) { if let url = URL(string: article.torrentURL ?? article.link ?? "") { TorrentAddView(torrentUrls: .constant([url])) } } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /qBitControl/Views/RSSViews/RSSFeedView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RSSFeedView: View { 4 | @ObservedObject var rssNodeViewModel = RSSNodeViewModel.shared 5 | 6 | @State public var rssFeed: RSSFeed 7 | @State public var searchQuery: String = "" 8 | 9 | func filterArticles(_ article: RSSFeed.Article) -> Bool { 10 | if let title = article.title { return title.lowercased().contains(searchQuery.lowercased()) } 11 | return false 12 | } 13 | 14 | var body: some View { 15 | List { 16 | if !searchQuery.isEmpty { 17 | Section(header: Text("\(rssFeed.articles.count) Articles") ) { 18 | ForEach(rssFeed.articles.filter(filterArticles), id: \.id) { article in 19 | RSSArticleView(article: article) 20 | } 21 | } 22 | } else { 23 | Section(header: Text("\(rssFeed.articles.count) Articles")) { 24 | ForEach(rssFeed.articles, id: \.id) { article in 25 | RSSArticleView(article: article) 26 | } 27 | } 28 | } 29 | } 30 | .navigationTitle(rssFeed.title) 31 | .searchable(text: $searchQuery) 32 | .onAppear { if self.rssFeed.title.isEmpty { rssNodeViewModel.getRssRootNode() } } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /qBitControl/Views/RSSViews/RSSNodeView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RSSNodeView: View { 4 | @State public var path: [String] 5 | @ObservedObject var viewModel = RSSNodeViewModel.shared 6 | var rssNode: RSSNode { viewModel.rssRootNode.getNode(path: path)! } 7 | 8 | @State private var isAddFeedAlert: Bool = false 9 | @State private var isAddFolderAlert: Bool = false 10 | @State private var isRenameAlert: Bool = false 11 | 12 | @State private var newFeedURL = "" 13 | @State private var newFolderName = "" 14 | @State private var newRenameName = "" 15 | @State private var oldRenamePath = "" 16 | 17 | var isRootView: Bool { 18 | self.path.count == 1 19 | } 20 | 21 | func getItemPath(item: String) -> String { 22 | var path = self.path + [item] 23 | path.removeFirst() 24 | return path.joined(separator: "\\") 25 | } 26 | 27 | var body: some View { 28 | List { 29 | if isRootView { 30 | Section(header: Text("Search")) { 31 | NavigationLink { 32 | SearchView() 33 | } label: { 34 | Label("Search", systemImage: "magnifyingglass") 35 | } 36 | } 37 | } 38 | 39 | Section(header: sectionHeader()) { 40 | ForEach(rssNode.nodes, id: \.id) { node in 41 | NavigationLink { 42 | RSSNodeView(path: path + [node.title]) 43 | } label: { 44 | Label(node.title, systemImage: "folder.fill").contextMenu(menuItems: { itemContextMenu(itemTitle: node.title, isFolder: true) }) 45 | } 46 | } 47 | 48 | ForEach(rssNode.feeds, id: \.id) { feed in 49 | NavigationLink { 50 | RSSFeedView(rssFeed: feed) 51 | } label: { 52 | Label(feed.title.isEmpty ? "Feed" : feed.title, systemImage: "dot.radiowaves.up.forward").contextMenu(menuItems: { itemContextMenu(itemTitle: feed.title) }) 53 | } 54 | } 55 | } 56 | }.navigationTitle(viewModel.rssRootNode.getNode(path: path)!.title) 57 | .refreshable { refresh() } 58 | .toolbar { toolbar() } 59 | .alert("Add Feed", isPresented: $isAddFeedAlert, actions: { addFeedAlert() }) 60 | .alert("Add Folder", isPresented: $isAddFolderAlert, actions: { addFolderAlert() }) 61 | .alert("New Name", isPresented: $isRenameAlert, actions: { renameAlert() }) 62 | } 63 | 64 | func sectionHeader() -> Text { 65 | var header: [String] = [] 66 | if(!rssNode.nodes.isEmpty) { header.append("\(rssNode.nodes.count)" + " " + String(localized: "Folders")) } 67 | if(!rssNode.feeds.isEmpty) { header.append("\(rssNode.feeds.count)" + " " + String(localized: "Feeds")) } 68 | 69 | return Text(header.joined(separator: " • ")) 70 | } 71 | 72 | func toolbar() -> some ToolbarContent { 73 | ToolbarItem(placement: .topBarTrailing) { 74 | Menu { 75 | Button { isAddFeedAlert = true } label: { Label("Add Feed", systemImage: "dot.radiowaves.up.forward") } 76 | Button { isAddFolderAlert = true } label: { Label("Add Folder", systemImage: "folder.badge.plus") } 77 | } label: { 78 | Image(systemName: "plus") 79 | } 80 | } 81 | } 82 | 83 | func addFeedAlert() -> some View { 84 | VStack { 85 | TextField("URL", text: $newFeedURL) 86 | Button("Add") { 87 | if self.newFeedURL.isEmpty { return } 88 | var path = self.path + [newFeedURL] 89 | path.removeFirst() 90 | qBittorrent.addRSSFeed(url: newFeedURL, path: path.joined(separator: "\\")) 91 | newFeedURL = "" 92 | refresh() 93 | } 94 | Button("Cancel", role: .cancel) {} 95 | } 96 | } 97 | 98 | func addFolderAlert() -> some View { 99 | VStack { 100 | TextField("Name", text: $newFolderName) 101 | Button("Add") { 102 | let path = rssNode.getPath() 103 | if path.isEmpty { 104 | qBittorrent.addRSSFolder(path: newFolderName) 105 | } else { 106 | qBittorrent.addRSSFolder(path: rssNode.getPath() + "\\" + newFolderName) 107 | } 108 | newFolderName = "" 109 | refresh() 110 | } 111 | Button("Cancel", role: .cancel) {} 112 | } 113 | } 114 | 115 | func renameAlert() -> some View { 116 | VStack { 117 | TextField("Name", text: $newRenameName) 118 | Button("Save") { 119 | let newRenamePath = self.getItemPath(item: newRenameName) 120 | qBittorrent.moveRSSItem(itemPath: oldRenamePath, destPath: newRenamePath) 121 | 122 | oldRenamePath = "" 123 | newRenameName = "" 124 | refresh() 125 | } 126 | Button("Cancel", role: .cancel) {} 127 | } 128 | } 129 | 130 | func itemContextMenu(itemTitle: String, isFolder: Bool = false) -> some View { 131 | VStack { 132 | if !isFolder { 133 | Button { 134 | qBittorrent.addRSSRefreshItem(path: self.getItemPath(item: itemTitle)) 135 | } label: { Label("Refresh", systemImage: "arrow.clockwise") } 136 | } 137 | Button { 138 | self.newRenameName = itemTitle 139 | self.oldRenamePath = self.getItemPath(item: itemTitle) 140 | self.isRenameAlert.toggle() 141 | } label: { Label("Rename", systemImage: "pencil") } 142 | Button(role: .destructive) { 143 | qBittorrent.addRSSRemoveItem(path: self.getItemPath(item: itemTitle)) 144 | } label: { Label("Remove", systemImage: "trash") } 145 | } 146 | } 147 | 148 | func refresh() { viewModel.getRssRootNode() } 149 | } 150 | 151 | -------------------------------------------------------------------------------- /qBitControl/Views/RSSViews/RSSView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RSSView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct RSSView: View { 9 | var body: some View { 10 | VStack { 11 | NavigationStack { 12 | RSSNodeView(path: ["RSS"]) 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /qBitControl/Views/SearchViews/SearchFiltersView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SearchFiltersView: View { 4 | @Environment(\.dismiss) var dismiss 5 | @ObservedObject var viewModel: SearchViewModel 6 | 7 | var body: some View { 8 | NavigationStack { 9 | List { 10 | Section(header: Text("Descending")) { 11 | Toggle(isOn: $viewModel.isDescending, label: { Text("Descending") }) 12 | .onChange(of: viewModel.isDescending) { _ in 13 | viewModel.saveFilters() 14 | } 15 | } 16 | 17 | Picker("Sort By", selection: $viewModel.sortBy) { 18 | ForEach(SearchSortOptions.allCases, id: \.self) { option in 19 | Text(LocalizedStringResource(stringLiteral: option.rawValue.capitalized)).tag(option) 20 | } 21 | }.pickerStyle(.inline) 22 | .onChange(of: viewModel.sortBy) { _ in 23 | viewModel.saveFilters() 24 | } 25 | }.toolbar { 26 | ToolbarItem(placement: .topBarTrailing) { 27 | Button { 28 | self.dismiss() 29 | } label: { 30 | Text("Done") 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /qBitControl/Views/SearchViews/SearchRowView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SearchRowView: View { 4 | let result: SearchResult 5 | let onTap: (SearchResult) -> Void 6 | 7 | var body: some View { 8 | VStack { 9 | HStack { 10 | Text(result.fileName ?? "") 11 | .lineLimit(2) 12 | Spacer() 13 | } 14 | HStack(spacing: 3) { 15 | Text(qBittorrent.getFormatedSize(size: result.fileSize ?? 0)) 16 | Text("•") 17 | Group { 18 | Image(systemName: "square.and.arrow.up") 19 | Text("\(result.nbSeeders ?? 0)") 20 | } 21 | Text("•") 22 | Group { 23 | Image(systemName: "square.and.arrow.down") 24 | Text("\(result.nbLeechers ?? 0)") 25 | } 26 | Spacer() 27 | }.font(.footnote) 28 | .foregroundStyle(Color.gray) 29 | }.contentShape(Rectangle()) 30 | .onTapGesture { 31 | self.onTap(result) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /qBitControl/Views/SearchViews/SearchView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SearchView: View { 4 | @StateObject var viewModel = SearchViewModel() 5 | 6 | var body: some View { 7 | ZStack { 8 | List { 9 | Section(header: Text("Search")) { 10 | HStack { 11 | TextField("Search", text: $viewModel.query) 12 | .autocorrectionDisabled(true) 13 | .autocapitalization(.none) 14 | .keyboardType(.default) 15 | 16 | if !viewModel.isRunning { 17 | Button { 18 | viewModel.startSearch() 19 | } label: { 20 | Text("Start") 21 | } 22 | } else { 23 | Button { 24 | viewModel.endSearch() 25 | } label: { 26 | Text("Stop") 27 | .foregroundStyle(.red) 28 | } 29 | } 30 | } 31 | 32 | Picker("Category", selection: $viewModel.category) { 33 | ForEach(self.viewModel.categoriesArray, id: \.self) { category in 34 | Text(LocalizedStringKey(category.name)).tag(category) 35 | } 36 | } 37 | } 38 | 39 | if viewModel.isResponse { 40 | Section(header: Text("\(viewModel.lastestTotal)") + Text(" ") + Text("results")) { 41 | ForEach(viewModel.latestResults, id: \.id) { result in 42 | SearchRowView(result: result, onTap: self.viewModel.onRowTap) 43 | } 44 | } 45 | } 46 | } 47 | 48 | if !viewModel.isResponse { 49 | VStack { 50 | Text("No results") 51 | }.foregroundStyle(.gray) 52 | } 53 | }.toolbar { 54 | ToolbarItem(placement: .topBarTrailing) { 55 | Button { 56 | self.viewModel.isFilterSheet.toggle() 57 | } label: { 58 | Image(systemName: "line.3.horizontal.decrease.circle") 59 | } 60 | } 61 | }.sheet(isPresented: $viewModel.isFilterSheet) { 62 | SearchFiltersView(viewModel: viewModel) 63 | }.sheet(isPresented: $viewModel.isTorrentAddSheet) { if let url = URL(string: self.viewModel.tappedResult?.fileUrl ?? "") { TorrentAddView(torrentUrls: .constant([url]), magnetOverride: true) } } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /qBitControl/Views/ServersViews/ServerAddView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerAddView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct ServerAddView: View { 9 | @Environment(\.dismiss) var dismiss 10 | 11 | @StateObject private var viewModel: ServerAddViewModel 12 | 13 | init() { 14 | _viewModel = StateObject(wrappedValue: ServerAddViewModel()) 15 | } 16 | 17 | init(editServerId: String) { 18 | _viewModel = StateObject(wrappedValue: ServerAddViewModel(editServerId: editServerId)) 19 | } 20 | 21 | var body: some View { 22 | NavigationView { 23 | List { 24 | Section(header: Text("Information")) { 25 | TextField("Server Name (optional)", text: $viewModel.friendlyName) 26 | .autocapitalization(.none) 27 | .autocorrectionDisabled() 28 | TextField("http(s)://IP:PORT", text: $viewModel.url) 29 | .keyboardType(.URL) 30 | .autocapitalization(.none) 31 | .autocorrectionDisabled() 32 | TextField("Username", text: $viewModel.username) 33 | .autocapitalization(.none) 34 | .autocorrectionDisabled() 35 | SecureField("Password", text: $viewModel.password) 36 | .autocorrectionDisabled() 37 | } 38 | 39 | Section { 40 | Toggle(isOn: $viewModel.isCheckConnection, label: { 41 | Text("Check Connection") 42 | }) 43 | } 44 | 45 | Section { 46 | Button { 47 | viewModel.addServer(dismiss: dismiss) 48 | } label: { 49 | Spacer() 50 | if(viewModel.isCheckingConnection) { 51 | Text("ADDING" + "...") 52 | .fontWeight(.bold) 53 | } else { 54 | Text("ADD") 55 | .fontWeight(.bold) 56 | } 57 | Spacer() 58 | }.buttonStyle(.borderedProminent) 59 | .tint(viewModel.addButtonColor) 60 | }.listRowBackground(viewModel.addButtonColor) 61 | } 62 | .alert(isPresented: $viewModel.isInvalidAlert) { 63 | Alert(title: Text("Invalid server information"), message: Text(viewModel.invalidAlertMessage), dismissButton: .default(Text("OK"), action: { 64 | viewModel.alertDismissed() 65 | })) 66 | } 67 | .toolbar() { 68 | Button { 69 | dismiss() 70 | } label: { 71 | Text("Cancel") 72 | } 73 | } 74 | .navigationTitle("Add Server") 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /qBitControl/Views/ServersViews/ServerRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerRowView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct ServerRowView: View { 9 | @ObservedObject var serversHelper = ServersHelper.shared 10 | @State var server: Server 11 | @State var setActiveSheet: (ActiveSheet) -> Void 12 | 13 | var body: some View { 14 | HStack() { 15 | Text(server.name.isEmpty ? server.url : server.name) 16 | 17 | Spacer() 18 | 19 | if(serversHelper.activeServerId == server.id) { 20 | Image(systemName: "checkmark") 21 | } 22 | else if(serversHelper.connectingServerId == server.id) { 23 | ProgressView() 24 | .progressViewStyle(.circular) 25 | .padding(.leading, 1) 26 | } 27 | } 28 | .contextMenu() { 29 | Button { 30 | setActiveSheet(.edit(serverId: server.id)) 31 | } label: { 32 | Label("Edit", systemImage: "pencil") 33 | } 34 | 35 | Button(role: .destructive) { 36 | serversHelper.removeServer(id: server.id) 37 | } label: { 38 | Label("Delete", systemImage: "trash") 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /qBitControl/Views/ServersViews/ServersView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum ActiveSheet: Identifiable { 4 | case add 5 | case edit(serverId: String) 6 | 7 | var id: String { 8 | switch self { 9 | case .add: 10 | return "add" 11 | case .edit(let serverId): 12 | return serverId 13 | } 14 | } 15 | } 16 | 17 | struct ServersView: View { 18 | @ObservedObject var serversHelper = ServersHelper.shared 19 | 20 | @State var activeSheet: ActiveSheet? 21 | @State var isTroubleConnecting = false 22 | 23 | func setActiveSheet(sheet: ActiveSheet) { 24 | activeSheet = sheet 25 | } 26 | 27 | func sortServers(server1: Server, server2: Server) -> Bool { 28 | let name1 = server1.name.isEmpty ? server1.url : server1.name 29 | let name2 = server2.name.isEmpty ? server2.url : server2.name 30 | 31 | return name1 < name2 32 | } 33 | 34 | var body: some View { 35 | NavigationStack { 36 | List { 37 | Section(header: Text("Manage")) { 38 | Button { 39 | activeSheet = .add 40 | } label: { 41 | HStack { 42 | Image(systemName: "plus.circle") 43 | Text("Add Server") 44 | } 45 | } 46 | } 47 | if !serversHelper.servers.isEmpty { 48 | Section(header: Text("Server List")) { 49 | ForEach(serversHelper.servers.sorted(by: sortServers), id: \.id) { server in 50 | Button { 51 | serversHelper.connect(server: server, result: { success in 52 | if(!success) { isTroubleConnecting = true } 53 | }) 54 | } label: { 55 | ServerRowView(server: server, setActiveSheet: setActiveSheet) 56 | } 57 | } 58 | } 59 | } 60 | } 61 | .navigationTitle("Servers") 62 | } 63 | .sheet(item: $activeSheet) { item in 64 | switch item { 65 | case .add: 66 | ServerAddView() 67 | case .edit(let serverId): 68 | ServerAddView(editServerId: serverId) 69 | } 70 | } 71 | .alert(isPresented: $isTroubleConnecting) { 72 | Alert(title: Text("Couldn't connect to the server."), message: Text("Check if the URL, username and password is correct. Make sure local network access is enabled:\nSettings > Privacy & Security > Local Network > qBitControl")) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import Foundation 4 | import SwiftUI 5 | 6 | struct AboutView: View { 7 | @Environment(\.dismiss) var dismiss 8 | 9 | @ObservedObject var appInfo = AppInfo.shared 10 | 11 | var body: some View { 12 | NavigationView { 13 | VStack { 14 | Image("logo") 15 | .resizable() 16 | .scaledToFit() 17 | .frame(width: 100, height: 100) 18 | .cornerRadius(20) 19 | Text("qBitControl \(appInfo.version) (\(appInfo.build))") 20 | .font(.title) 21 | Text("qBitControl is the definitive remote client for managing your qBittorrent downloads on iOS devices.") 22 | .multilineTextAlignment(.center) 23 | List { 24 | Section(header: Text("Information")) { 25 | Link("GitHub", destination: URL(string: "https://github.com/Michael-128/qBitControl")!) 26 | Link("Patreon", destination: URL(string: "https://patreon.com/michael128")!) 27 | Link("Report a bug", destination: URL(string: "https://github.com/Michael-128/qBitControl/issues")!) 28 | Link("Help with translation", destination: URL(string: "https://crowdin.com/project/qbitcontrol/invite?h=3bc475d8145450c4770cae83a742583c2277091")!) 29 | } 30 | } 31 | } 32 | .background(Color(.systemGroupedBackground)) 33 | .toolbar() { 34 | ToolbarItem(placement: .navigationBarTrailing) { 35 | Button { 36 | dismiss() 37 | } label: { 38 | Text("Done") 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/FilesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsFilesView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct FilesView: View { 9 | @Binding var torrentHash: String 10 | 11 | @State private var sortedFiles: [Dictionary.Element] = [] 12 | @State private var rootFileNodes: [FileNode] = [] 13 | @StateObject private var rootFileNode = FileNode(name: "") 14 | 15 | @State private var isLoaded = false 16 | @State private var searchQuery = "" 17 | 18 | // Max dirs loaded at a time 19 | let step = 50 20 | @State private var curStep = 0 21 | 22 | func getNextStep(currentStep: Int) -> Int { 23 | let nextStep = currentStep + step 24 | self.curStep = nextStep + 1 25 | return nextStep 26 | } 27 | 28 | func getFiles() { 29 | isLoaded = false 30 | 31 | let request = qBitRequest.prepareURLRequest(path: "/api/v2/torrents/files", queryItems: [URLQueryItem(name: "hash", value: torrentHash)]) 32 | 33 | qBitRequest.requestFilesJSON(request: request, completionHandler: { 34 | files in 35 | 36 | var filesWithCommonPaths: [String: [FileNode]] = ["":[]] 37 | 38 | for file in files { 39 | var fileComponents = file.name.components(separatedBy: "/") 40 | let actualFilename = fileComponents.last! 41 | let _ = fileComponents.popLast() 42 | let path = fileComponents.joined(separator: "/") 43 | filesWithCommonPaths[path, default: []].append(FileNode(index: file.index, name: actualFilename, size: file.size, progress: file.progress, priority: file.priority, is_seed: file.is_seed, availability: file.availability)) 44 | } 45 | 46 | self.sortedFiles = filesWithCommonPaths.sorted(by: { $0.0 < $1.0 }) 47 | getNextFileNodes(startIndex: curStep, endIndex: getNextStep(currentStep: curStep)) 48 | }) 49 | } 50 | 51 | func getNextFileNodes(startIndex: Int, endIndex: Int) { 52 | var newEndIndex = endIndex 53 | if endIndex > sortedFiles.count - 1 { 54 | newEndIndex = sortedFiles.count - 1 55 | } 56 | if startIndex > newEndIndex { 57 | return 58 | } 59 | 60 | 61 | let rootFileNode = self.rootFileNode 62 | 63 | for index in startIndex...newEndIndex { 64 | let path = sortedFiles[index].key 65 | let files = sortedFiles[index].value 66 | 67 | 68 | let pathComponents = path.components(separatedBy: "/") 69 | 70 | 71 | var lastFileNode = rootFileNode 72 | 73 | for pathComponent in pathComponents { 74 | if let existingFileNode = lastFileNode.shallowSearch(name: pathComponent) { 75 | lastFileNode = existingFileNode 76 | } else { 77 | let newFileNode = FileNode(name: pathComponent) 78 | lastFileNode.add(child: newFileNode) 79 | lastFileNode = newFileNode 80 | } 81 | } 82 | 83 | lastFileNode.addMultiple(children: files) 84 | } 85 | 86 | self.rootFileNodes = self.rootFileNode.children ?? [] 87 | 88 | isLoaded = true 89 | 90 | getNextFileNodes(startIndex: curStep, endIndex: getNextStep(currentStep: curStep)) 91 | } 92 | 93 | func getPriorityColor(fileNode: FileNode) -> Color { 94 | return fileNode.getPriority() > 0 ? Color.primary : Color.gray 95 | } 96 | 97 | func setPriority(indexes: String, priority: Int, onComplete: @escaping (Bool) -> Void) { 98 | let request = qBitRequest.prepareURLRequest(path: "/api/v2/torrents/filePrio", queryItems: [ 99 | URLQueryItem(name: "hash", value: torrentHash), 100 | URLQueryItem(name: "id", value: indexes), 101 | URLQueryItem(name: "priority", value: "\(priority)") 102 | ]) 103 | 104 | qBitRequest.requestTorrentManagement(request: request, statusCode: { 105 | code in 106 | if let code = code { 107 | if code == 200 { 108 | onComplete(true) 109 | } else { 110 | onComplete(false) 111 | } 112 | } else { 113 | onComplete(false) 114 | } 115 | }) 116 | } 117 | 118 | func refresh() { 119 | rootFileNode.objectWillChange.send() 120 | self.rootFileNodes = rootFileNode.children ?? [] 121 | } 122 | 123 | func search() { 124 | if searchQuery.isEmpty { 125 | self.rootFileNodes = rootFileNode.children ?? [] 126 | } else { 127 | self.rootFileNodes = rootFileNode.findAll(query: searchQuery) 128 | } 129 | } 130 | 131 | var body: some View { 132 | VStack { 133 | if rootFileNodes.count > 0 { 134 | List(rootFileNodes, children: \.children/*, selection: $selection*/) { 135 | child in 136 | HStack { 137 | if child.isDir { 138 | Image(systemName: "folder.fill") 139 | .foregroundColor(getPriorityColor(fileNode: child)) 140 | } else { 141 | Image(systemName: "doc.fill") 142 | .foregroundColor(getPriorityColor(fileNode: child)) 143 | } 144 | Text("\(child.name)") 145 | Spacer() 146 | Text("\(qBittorrent.getFormatedSize(size: child.getSize()))") 147 | .foregroundColor(Color.gray) 148 | }.contextMenu() { 149 | if child.getPriority() < 1 { 150 | Button { 151 | let stringArr = child.getIndexes().map { String($0) } 152 | let indexes = stringArr.joined(separator: "|") 153 | 154 | setPriority(indexes: indexes, priority: 1, onComplete: { 155 | success in 156 | if success { 157 | for index in child.getIndexes() { 158 | if let childOfChild = child.search(index: index) { 159 | childOfChild.setPriority(priority: 1) 160 | refresh() 161 | } 162 | } 163 | } 164 | }) 165 | } label: { 166 | Text("Download") 167 | Image(systemName: "arrow.down") 168 | } 169 | } else { 170 | Button { 171 | let childIndexes = child.getIndexes() 172 | let stringArr = childIndexes.map { String($0) } 173 | let indexes = stringArr.joined(separator: "|") 174 | 175 | setPriority(indexes: indexes, priority: 0, onComplete: { 176 | success in 177 | if success { 178 | for index in childIndexes { 179 | if let childOfChild = child.search(index: index) { 180 | childOfChild.setPriority(priority: 0) 181 | refresh() 182 | } 183 | } 184 | } 185 | }) 186 | } label: { 187 | Text("Do not download") 188 | Image(systemName: "xmark") 189 | } 190 | } 191 | } 192 | }.navigationTitle("Files") 193 | } 194 | 195 | 196 | if !isLoaded { 197 | ProgressView().progressViewStyle(.circular) 198 | .navigationTitle("Files") 199 | } 200 | 201 | } 202 | .searchable(text: $searchQuery) 203 | .onAppear() { 204 | getFiles() 205 | }.onSubmit(of: .search, search) 206 | .onChange(of: searchQuery) { 207 | _ in 208 | if searchQuery.isEmpty {search()} 209 | } 210 | } 211 | } 212 | 213 | struct TorrentDetailsFilesView_Previews: PreviewProvider { 214 | static var previews: some View { 215 | MainView() 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/FiltersMenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentFilterView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct FiltersMenuView: View { 9 | @Environment(\.presentationMode) var presentationMode 10 | 11 | @Binding var sort: String 12 | @Binding var reverse: Bool 13 | @Binding var filter: String 14 | 15 | @State private var categories: [Category] = [] 16 | @State private var tagsArr: [String] = [] 17 | 18 | @Binding var category: String 19 | @Binding var tag: String 20 | 21 | 22 | private let defaults = UserDefaults.standard 23 | 24 | 25 | var body: some View { 26 | NavigationView { 27 | Form { 28 | Section(header: Text("Descending")) { 29 | Toggle(isOn: $reverse) { 30 | Text("Descending") 31 | }.onChange(of: reverse) { value in 32 | defaults.set(reverse, forKey: "reverse") 33 | } 34 | } 35 | 36 | if categories.count > 1 { 37 | Picker("Categories", selection: $category) { 38 | Text("All").tag("All") 39 | Text("Uncategorized").tag("") 40 | ForEach(categories, id: \.self) { theCategory in 41 | Text(theCategory.name).tag(theCategory.name) 42 | } 43 | }.pickerStyle(.inline) 44 | } 45 | 46 | if tagsArr.count > 1 { 47 | Picker("Tags", selection: $tag) { 48 | Text("All").tag("All") 49 | Text("Untagged").tag("") 50 | ForEach(tagsArr, id: \.self) { 51 | tag1 in 52 | Text(tag1).tag(tag1) 53 | } 54 | }.pickerStyle(.inline) 55 | } 56 | 57 | Picker("Filter By", selection: $filter) { 58 | Group { 59 | Text("All").tag("all") 60 | Text("Resumed").tag("resumed") 61 | Text("Seeding").tag("stalled_uploading") 62 | Text("Downloading").tag("stalled_downloading") 63 | Text("Active Downloading").tag("downloading") 64 | Text("Active Seeding").tag("seeding") 65 | Text("Completed").tag("completed") 66 | Text("Paused").tag("paused") 67 | Text("Active").tag("active") 68 | Text("Inactive").tag("inactive") 69 | } 70 | Group { 71 | Text("Stalled").tag("stalled") 72 | Text("Errored").tag("errored") 73 | } 74 | }.pickerStyle(.inline) 75 | .onChange(of: filter, perform: { 76 | value in 77 | defaults.set(filter, forKey: "filter") 78 | }) 79 | 80 | Picker("Sort By", selection: $sort) { 81 | Group { 82 | Text("Added On").tag("added_on") 83 | Text("Amount Left").tag("amount_left") 84 | //Text("Auto Torrent Management").tag("auto_tmm") 85 | Text("Availability").tag("availability") 86 | Text("Category").tag("category") 87 | Text("Completed").tag("completed") 88 | Text("Completion On").tag("completion_on") 89 | //Text("Content Path").tag("content_path") 90 | Text("Download Limit").tag("dl_limit") 91 | Text("Download Speed").tag("dlspeed") 92 | } 93 | 94 | Group { 95 | Text("Downloaded").tag("downloaded") 96 | Text("Downloaded Session").tag("downloaded_session") 97 | Text("ETA").tag("eta") 98 | //Text("FL Piece Ratio").tag("f_l_piece_prio") 99 | //Text("Force Start").tag("force_start") 100 | //Text("Hash").tag("hash") 101 | Text("Last Activity").tag("last_activity") 102 | //Text("Magnet URI").tag("magnet_uri") 103 | Text("Max Ratio").tag("max_ratio") 104 | Text("Max Seeding Time").tag("max_seeding_time") 105 | } 106 | 107 | Group { 108 | Text("Name").tag("name") 109 | Text("Seeds In Swarm").tag("num_complete") 110 | Text("Peers In Swarm").tag("num_incomplete") 111 | Text("Connected Leeches").tag("num_leechs") 112 | Text("Connected Seeds").tag("num_seeds") 113 | Text("Priority").tag("priority") 114 | Text("Progress").tag("progress") 115 | Text("Ratio").tag("ratio") 116 | Text("Ratio Limit").tag("ratio_limit") 117 | } 118 | 119 | Group { 120 | //Text("Save Path").tag("save_path") 121 | Text("Seeding Time").tag("seeding_time") 122 | Text("Seeding Time Limit").tag("seeding_time_limit") 123 | //Text("Seen Complete").tag("seen_complete") 124 | //Text("Seq DL").tag("seq_dl") 125 | Text("Size").tag("size") 126 | Text("State").tag("state") 127 | //Text("Super Seeding").tag("super_seeding") 128 | Text("Tags").tag("tags") 129 | Text("Time Active").tag("time_active") 130 | Text("Total Size").tag("total_size") 131 | } 132 | 133 | Group { 134 | //Text("Tracker").tag("tracker") 135 | Text("Upload Limit").tag("up_limit") 136 | Text("Uploaded").tag("uploaded") 137 | Text("Uploaded Session").tag("uploaded_session") 138 | Text("Upload Speed").tag("upspeed") 139 | } 140 | }.pickerStyle(.inline) 141 | .onChange(of: sort, perform: { 142 | value in 143 | defaults.set(sort, forKey: "sort") 144 | }) 145 | 146 | .navigationBarTitle("Filters") 147 | }.toolbar() { 148 | ToolbarItem(placement: .navigationBarTrailing) { 149 | Button { 150 | presentationMode.wrappedValue.dismiss() 151 | } label: { 152 | Text("Done") 153 | } 154 | } 155 | }.onAppear() { 156 | qBittorrent.getCategories(completionHandler: { response in 157 | // Append sorted list of Category objects to ensure "None" always appears at the top 158 | self.categories.append(contentsOf: response.map { $1 }.sorted { $0.name < $1.name }) 159 | }) 160 | qBittorrent.getTags(completionHandler: { 161 | tags in 162 | tags.forEach({ 163 | tag in 164 | tagsArr.append(tag) 165 | }) 166 | }) 167 | } 168 | } 169 | } 170 | } 171 | 172 | /*struct TorrentFilterView_Previews: PreviewProvider { 173 | static var previews: some View { 174 | NavigationView { 175 | VStack {} 176 | .sheet(isPresented: .constant(true), content: { 177 | TorrentFilterView(sort: .constant("name"), reverse: .constant(false), filter: .constant("all")) 178 | }) 179 | } 180 | } 181 | }*/ 182 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/PeerDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentPeersDetailsView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct PeerDetailsView: View { 9 | 10 | @Binding var peer: Peer 11 | 12 | var body: some View { 13 | List { 14 | Section(header: Text("Information")) { 15 | CustomLabelView(label: "Client", value: peer.client) 16 | CustomLabelView(label: "Country", value: peer.country) 17 | CustomLabelView(label: "IP", value: peer.ip) 18 | CustomLabelView(label: "Port", value: "\(peer.port)") 19 | CustomLabelView(label: "Connection", value: peer.connection) 20 | CustomLabelView(label: "Files", value: peer.files) 21 | CustomLabelView(label: "Flags", value: peer.flags) 22 | } 23 | 24 | Section(header: Text("Activity")) { 25 | CustomLabelView(label: "Progress", value: "\(String(format: "%.1f", peer.progress*100))%") 26 | CustomLabelView(label: "Relevance", value: "\(String(format: "%.1f", peer.relevance*100))%") 27 | 28 | CustomLabelView(label: "Download Speed", value: "\(qBittorrent.getFormatedSize(size: peer.dl_speed))/s") 29 | CustomLabelView(label: "Downloaded", value: "\(qBittorrent.getFormatedSize(size: peer.downloaded))") 30 | 31 | CustomLabelView(label: "Upload Speed", value: "\(qBittorrent.getFormatedSize(size: peer.up_speed))/s") 32 | CustomLabelView(label: "Uploaded", value: "\(qBittorrent.getFormatedSize(size: peer.uploaded))") 33 | } 34 | 35 | .navigationTitle("Peer Details") 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/PeersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsPeersView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct PeersView: View { 9 | @Binding var torrentHash: String 10 | 11 | @State private var peers: [Peer] = [] 12 | @State private var timer: Timer? 13 | @State private var isLoaded = false 14 | 15 | func getPeers() { 16 | var refreshedPeers: [Peer] = [] 17 | 18 | let request = qBitRequest.prepareURLRequest(path: "/api/v2/sync/torrentPeers", queryItems: [URLQueryItem(name: "hash", value: torrentHash)]) 19 | 20 | qBitRequest.requestPeersJSON(request: request, completionHandler: { 21 | peers in 22 | for (_, value) in peers.peers { 23 | refreshedPeers.append(value) 24 | } 25 | refreshedPeers.sort(by: {$0.dl_speed > $1.dl_speed}) 26 | self.peers = refreshedPeers 27 | self.isLoaded = true 28 | }) 29 | } 30 | 31 | var body: some View { 32 | VStack { 33 | if isLoaded { 34 | List { 35 | Section(header: Text("\(peers.count) " + NSLocalizedString("Peers", comment: ""))) { 36 | ForEach($peers, id: \.ip) { 37 | peer in 38 | PeerRowView(peer: peer) 39 | } 40 | } 41 | 42 | .navigationTitle("Peers") 43 | } 44 | } else { 45 | ProgressView().progressViewStyle(.circular) 46 | .navigationTitle("Peers") 47 | } 48 | }.onAppear() { 49 | getPeers() 50 | timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { 51 | timer in 52 | getPeers() 53 | } 54 | }.onDisappear() { 55 | timer?.invalidate() 56 | } 57 | } 58 | } 59 | 60 | struct TorrentDetailsPeersView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | NavigationView { 63 | PeerRowView(peer: .constant(Peer(client: "example client", connection: "BT", country: "Poland", country_code: "pl", dl_speed: 10000, downloaded: 100000, files: "example file", flags: "e", flags_desc: "e", ip: "192.168.1.1", port: 22222, progress: 1, relevance: 1, up_speed: 10000, uploaded: 10000))) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/StatsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | 5 | struct StatsView: View { 6 | 7 | @ObservedObject var qBitDataShared = qBitData.shared 8 | 9 | @State private var timer: Timer? 10 | private var fetchInterval: TimeInterval = 2 11 | 12 | var body: some View { 13 | NavigationStack { 14 | VStack { 15 | List { 16 | Section(header: Text("Download")) { 17 | CustomLabelView(label: "Session Download", value: "\(qBittorrent.getFormatedSize(size: qBitDataShared.serverState?.dl_info_data ?? 0))") 18 | CustomLabelView(label: "Download Speed", value: "\(qBittorrent.getFormatedSize(size: qBitDataShared.serverState?.dl_info_speed ?? 0))/s") 19 | StatsChartView(transferData: $qBitDataShared.dlTransferData, color: .green) 20 | } 21 | 22 | Section(header: Text("Upload")) { 23 | CustomLabelView(label: "Session Upload", value: "\(qBittorrent.getFormatedSize(size: qBitDataShared.serverState?.up_info_data ?? 0))") 24 | CustomLabelView(label: "Upload Speed", value: "\(qBittorrent.getFormatedSize(size: qBitDataShared.serverState?.up_info_speed ?? 0))/s") 25 | StatsChartView(transferData: $qBitDataShared.upTransferData) 26 | } 27 | 28 | Section(header: Text("Disk")) { 29 | CustomLabelView(label: "Free Space", value: "\(qBittorrent.getFormatedSize(size: qBitDataShared.serverState?.free_space_on_disk ?? 0))") 30 | } 31 | 32 | Section(header: Text("All-Time")) { 33 | CustomLabelView(label: "Upload", value: "\(qBittorrent.getFormatedSize(size: qBitDataShared.serverState?.alltime_ul ?? 0))") 34 | CustomLabelView(label: "Download", value: "\(qBittorrent.getFormatedSize(size: qBitDataShared.serverState?.alltime_dl ?? 0))") 35 | CustomLabelView(label: "Ratio", value: "\(qBitDataShared.serverState?.global_ratio ?? "0.00")") 36 | } 37 | } 38 | }.navigationTitle("Statistics") 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/TorrentAddView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentAddView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | 9 | struct TorrentAddView: View { 10 | @Environment(\.dismiss) var dismissAction 11 | @StateObject var viewModel: TorrentAddViewModel 12 | 13 | @Binding var torrentUrls: [URL] 14 | 15 | func dismiss() { 16 | torrentUrls = [] 17 | dismissAction() 18 | } 19 | 20 | init(torrentUrls: Binding<[URL]> = .constant([]), magnetOverride: Bool = false) { 21 | _viewModel = StateObject(wrappedValue: TorrentAddViewModel(torrentUrls: torrentUrls.wrappedValue, magnetOverride: magnetOverride)) 22 | _torrentUrls = torrentUrls 23 | } 24 | 25 | var body: some View { 26 | NavigationView { 27 | List { 28 | Section { 29 | Picker("Task Type", selection: $viewModel.torrentType) { 30 | Text("File").tag(TorrentType.file) 31 | Text("URL").tag(TorrentType.magnet) 32 | } 33 | .padding(.horizontal, 40.0) 34 | .padding(.vertical, 0) 35 | .pickerStyle(.segmented) 36 | .listRowBackground(Color.clear) 37 | } 38 | 39 | 40 | if(viewModel.torrentType == .magnet) { torrentMagnetView() } 41 | else if(viewModel.torrentType == .file) { torrentFilesView() } 42 | 43 | torrentOptionsView() 44 | } 45 | .onAppear() { 46 | viewModel.getSavePath() 47 | 48 | if(!viewModel.isAppeared) { 49 | viewModel.isAppeared.toggle() 50 | viewModel.checkTorrentType() 51 | } 52 | } 53 | .toolbar() { 54 | ToolbarItem(placement: .navigationBarLeading) { 55 | Button { dismiss() } label: { Text("Cancel") } 56 | } 57 | } 58 | } 59 | } 60 | 61 | 62 | // Helper Views 63 | 64 | func listElement(value: String) -> some View { 65 | Button(action: {UIPasteboard.general.string = "\(value)"}) { 66 | HStack { 67 | Text("\(value)") 68 | .foregroundColor(Color.gray) 69 | .lineLimit(1) 70 | } 71 | } 72 | } 73 | 74 | func limitField(title: String, placeholder: String, content: Binding) -> some View { 75 | HStack { 76 | Text(title) 77 | Spacer() 78 | TextField(placeholder, text: content).multilineTextAlignment(.trailing) 79 | } 80 | } 81 | 82 | func torrentMagnetView() -> some View { 83 | Group { 84 | Section(header: Text("URL")) { 85 | TextEditor(text: $viewModel.magnetURL) 86 | .frame(minHeight: CGFloat(200), maxHeight: CGFloat(200)) 87 | } 88 | 89 | } 90 | .navigationTitle("URL") 91 | } 92 | 93 | func torrentFilesView() -> some View { 94 | Group { 95 | Section(header: Text("Files")) { 96 | Button { viewModel.isFileImporter.toggle() } label: { Text("Open Files..") } 97 | 98 | ForEach(viewModel.fileNames, id: \.self) { fileName in listElement(value: fileName) } 99 | } 100 | .navigationTitle("File") 101 | } 102 | .fileImporter(isPresented: $viewModel.isFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: true, onCompletion: viewModel.handleTorrentFiles) 103 | } 104 | 105 | func torrentOptionsView() -> some View { 106 | Group { 107 | Section(header: Text("Save Path")) { TextField("Path", text: $viewModel.savePath) } 108 | 109 | Group { 110 | Section(header: Text("Info")) { 111 | NavigationLink { 112 | ChangeCategoryView(category: viewModel.category.name, onCategoryChange: { category in 113 | DispatchQueue.main.async { 114 | viewModel.category = category 115 | if !viewModel.autoTmmEnabled { viewModel.savePath = category.savePath } 116 | print(viewModel.savePath) 117 | } 118 | }) 119 | } label: { 120 | CustomLabelView(label: "Category", value: viewModel.category.name) 121 | } 122 | 123 | NavigationLink { 124 | ChangeTagsView(selectedTags: viewModel.selectedTags, onTagsChange: { selectedTags in 125 | DispatchQueue.main.async { 126 | viewModel.selectedTags = selectedTags 127 | } 128 | }) 129 | } label: { 130 | CustomLabelView(label: "Tags", value: viewModel.getTag()) 131 | } 132 | } 133 | } 134 | 135 | Section(header: Text("Management")) { 136 | if viewModel.showAdvancedOptions { Toggle(isOn: $viewModel.skipChecking) { Text("Skip Checking") } } 137 | Toggle(isOn: $viewModel.paused) { Text("Pause") } 138 | Toggle(isOn: $viewModel.sequentialDownload) { Text("Sequential Download") } 139 | } 140 | 141 | Section(header: Text("Advanced")) { 142 | Toggle(isOn: $viewModel.showAdvancedOptions) { Text("Show Advanced Options") } 143 | } 144 | 145 | Section(header: Text("Limits")) { 146 | Toggle(isOn: $viewModel.showLimits) { Text("Limits") } 147 | if viewModel.showLimits { 148 | limitField(title: "Download Limit", placeholder: "0 bytes/s", content: $viewModel.downloadLimit) 149 | limitField(title: "Upload Limit", placeholder: "0 bytes/s", content: $viewModel.uploadLimit) 150 | limitField(title: "Ratio Limit", placeholder: "Ratio Limit", content: $viewModel.ratioLimit) 151 | limitField(title: "Seeding Time Limit", placeholder: "Time Limit", content: $viewModel.seedingTimeLimit) 152 | } 153 | } 154 | 155 | Section { 156 | Button { viewModel.addTorrent(then: dismiss) } label: { Text("ADD").frame(maxWidth: .infinity).fontWeight(.bold) }.buttonStyle(.borderedProminent) 157 | }.listRowBackground(Color.blue) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/TorrentDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | 9 | struct TorrentDetailsView: View { 10 | @Environment(\.dismiss) var dismiss 11 | 12 | @StateObject private var trackersViewModel: TrackersViewModel 13 | @StateObject private var viewModel: TorrentDetailsViewModel 14 | 15 | init(torrent: Torrent) { 16 | _viewModel = StateObject(wrappedValue: TorrentDetailsViewModel(torrent: torrent)) 17 | _trackersViewModel = StateObject(wrappedValue: TrackersViewModel(torrentHash: torrent.hash)) 18 | } 19 | 20 | var body: some View { 21 | VStack { 22 | List { 23 | Section(header: Text("Management")) { 24 | Button { viewModel.toggleTorrentPause() } label: { Text(viewModel.isPaused() ? "Resume Task" : "Pause Task") } 25 | Button { viewModel.recheckTorrent() } label: { Text("Recheck Task") } 26 | Button { viewModel.reannounceTorrent() } label: { Text("Reannounce Task") } 27 | Button { viewModel.setForceStart(value: !viewModel.isForceStart()) } label: { Text(viewModel.isForceStart() ? "Stop Force Start" : "Force Start").foregroundColor(.yellow) } 28 | Button { viewModel.deleteTorrent() } label: { Text("Delete Task").foregroundColor(.red) } 29 | } 30 | 31 | if let preferences = qBittorrent.getSavedPreferences() { 32 | if(preferences.queueing_enabled == true) { 33 | Section(header: Text("Queue Management")) { 34 | CustomLabelView(label: "Priority", value: "\(viewModel.torrent.priority)") 35 | Button { viewModel.moveToTopPriority() } label: { Text("Move to Top") } 36 | Button { viewModel.increasePriority() } label: { Text("Move Up") } 37 | Button { viewModel.decreasePriority() } label: { Text("Move Down") } 38 | Button { viewModel.moveToBottomPriority() } label: { Text("Move to Bottom") } 39 | } 40 | } 41 | } 42 | 43 | Section(header: Text("Status")) { 44 | CustomLabelView(label: "State", value: viewModel.getState()) 45 | CustomLabelView(label: "Progress", value: viewModel.getProgress()) 46 | CustomLabelView(label: "ETA", value: viewModel.getETA()) 47 | CustomLabelView(label: "Download Speed", value: viewModel.getDownloadSpeed()) 48 | CustomLabelView(label: "Upload Speed", value: viewModel.getUploadSpeed()) 49 | CustomLabelView(label: "Downloaded", value: viewModel.getDownloaded()) 50 | CustomLabelView(label: "Uploaded", value: viewModel.getUploaded()) 51 | CustomLabelView(label: "Ratio", value: viewModel.getRatio()) 52 | } 53 | 54 | Section(header: Text("Information")) { 55 | CustomLabelView(label: "Name", value: viewModel.torrent.name) 56 | CustomLabelView(label: "Added On", value: viewModel.getAddedOn()) 57 | 58 | NavigationLink { 59 | ChangeCategoryView(torrentHash: viewModel.torrent.hash, category: viewModel.torrent.category, onCategoryChange: {_ in 60 | viewModel.getTorrent() 61 | }) 62 | } label: { 63 | CustomLabelView(label: "Categories", value: viewModel.getCategory()) 64 | } 65 | 66 | NavigationLink{ 67 | ChangeTagsView(torrentHash: viewModel.torrent.hash, selectedTags: viewModel.getTags()) 68 | } label: { 69 | CustomLabelView(label: "Tags", value: viewModel.getTag()) 70 | } 71 | 72 | CustomLabelView(label: "Size", value: viewModel.getSize()) 73 | CustomLabelView(label: "Total Size", value: viewModel.getTotalSize()) 74 | CustomLabelView(label: "Availability", value: viewModel.getAvailability()) 75 | } 76 | 77 | Section(header: Text("Session")) { 78 | CustomLabelView(label: "Downloaded", value: viewModel.getDownloadedSession()) 79 | CustomLabelView(label: "Uploaded", value: viewModel.getUploadedSession()) 80 | } 81 | 82 | Section(header: Text("Connections")) { 83 | NavigationLink { 84 | PeersView(torrentHash: .constant(viewModel.torrent.hash)) 85 | } label: { 86 | Text("Peers") 87 | } 88 | NavigationLink { 89 | TrackersView(viewModel: trackersViewModel) 90 | } label: { 91 | Text("Trackers") 92 | } 93 | } 94 | 95 | Section(header: Text("Files")) { 96 | NavigationLink { 97 | ChangePathView(path: viewModel.torrent.save_path, torrentHash: viewModel.torrent.hash) 98 | } label: { 99 | CustomLabelView(label: "Save Path", value: viewModel.torrent.save_path) 100 | } 101 | 102 | NavigationLink { 103 | FilesView(torrentHash: .constant(viewModel.torrent.hash)) 104 | } label: { 105 | Text("Files") 106 | } 107 | } 108 | 109 | Section(header: Text("Advanced")) { 110 | Toggle(isOn: $viewModel.isSequentialDownload, label: { Text("Sequential Download") }) 111 | .onChange(of: viewModel.isSequentialDownload, perform: { _ in viewModel.toggleSequentialDownload() }) 112 | Toggle(isOn: $viewModel.isFLPiecesFirst, label: { Text("First & Last Pieces First") }) 113 | .onChange(of: viewModel.isFLPiecesFirst, perform: { _ in viewModel.toggleFLPiecesFirst() }) 114 | } 115 | 116 | Section(header: Text("Limits")) { 117 | CustomLabelView(label: "Maximum Ratio", value: viewModel.getMaxRatio()) 118 | CustomLabelView(label: "Download Limit", value: viewModel.getDownloadLimit()) 119 | CustomLabelView(label: "Upload Limit", value: viewModel.getUploadLimit()) 120 | } 121 | 122 | } 123 | .navigationTitle("Details") 124 | } 125 | .onAppear() { viewModel.setRefreshTimer() } 126 | .onDisappear() { viewModel.removeRefreshTimer() } 127 | .confirmationDialog("Delete Task", isPresented: $viewModel.isDeleteAlert) { 128 | Button("Delete Task", role: .destructive) { viewModel.deleteTorrent(then: self.dismiss) } 129 | Button("Delete Task with Files", role: .destructive) { viewModel.deleteTorrentWithFiles(then: self.dismiss) } 130 | Button("Cancel", role: .cancel) { } 131 | }.refreshable() { viewModel.getTorrent() } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/TorrentListHelperView.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import SwiftUI 4 | 5 | struct TorrentListHelperView: View { 6 | @Environment(\.presentationMode) var presentationMode 7 | @Environment(\.scenePhase) var scenePhaseEnv 8 | 9 | @ObservedObject var viewModel: TorrentListHelperViewModel 10 | 11 | var body: some View { 12 | Section(header: torrentListHeader()) { 13 | ForEach(viewModel.filteredTorrents, id: \.hash) { torrent in torrentRowView(torrent: torrent) } 14 | } 15 | .onAppear { viewModel.getInitialTorrents() } 16 | .onDisappear { viewModel.stopTimer() } 17 | .onChange(of: scenePhaseEnv) { phase in viewModel.scenePhase = phase } 18 | .confirmationDialog("Delete Task", isPresented: $viewModel.isDeleteAlert) { deleteAlertView() } 19 | } 20 | 21 | // Helper Views 22 | 23 | // Torrent List 24 | 25 | func torrentListHeader() -> some View { 26 | HStack(spacing: 3) { 27 | Text("\(viewModel.filteredTorrents.count) " + NSLocalizedString("Tasks", comment: "")) 28 | Text("•") 29 | Image(systemName: "arrow.down") 30 | Text("\( qBittorrent.getFormatedSize(size: viewModel.filteredTorrents.compactMap({$0.dlspeed}).reduce(0, +)) )/s") 31 | Text("•") 32 | Image(systemName: "arrow.up") 33 | Text("\( qBittorrent.getFormatedSize(size: viewModel.filteredTorrents.compactMap({$0.upspeed}).reduce(0, +)) )/s") 34 | } 35 | .lineLimit(1) 36 | } 37 | 38 | 39 | // Torrent Rows 40 | 41 | func torrentRowView(torrent: Torrent) -> some View { 42 | if(viewModel.isSelectionMode) { return AnyView(torrentSelectionModeRowView(torrent: torrent)) } 43 | return AnyView(torrentStandardRowView(torrent: torrent)) 44 | } 45 | 46 | func torrentStandardRowView(torrent: Torrent) -> some View { 47 | NavigationLink { 48 | TorrentDetailsView(torrent: torrent) 49 | } label: { 50 | TorrentRowView(name: torrent.name, progress: torrent.progress, state: torrent.state, dlspeed: torrent.dlspeed, upspeed: torrent.upspeed, ratio: torrent.ratio) 51 | .contextMenu() { torrentRowContextMenu(torrent: torrent) } 52 | } 53 | } 54 | 55 | func torrentSelectionModeRowView(torrent: Torrent) -> some View { 56 | let isTorrentSelected = viewModel.selectedTorrents.contains(torrent) 57 | 58 | return HStack { 59 | Image(systemName: isTorrentSelected ? "checkmark.circle.fill" : "circle").scaleEffect(1.25).foregroundStyle(isTorrentSelected ? Color(.blue) : Color(.gray)) 60 | TorrentRowView(name: torrent.name, progress: torrent.progress, state: torrent.state, dlspeed: torrent.dlspeed, upspeed: torrent.upspeed, ratio: torrent.ratio) 61 | }.onTapGesture { 62 | if(isTorrentSelected) { viewModel.selectedTorrents.remove(torrent) } else { viewModel.selectedTorrents.insert(torrent) } 63 | } 64 | } 65 | 66 | 67 | // Context Menu 68 | 69 | func torrentRowQueueControls(torrent: Torrent) -> some View { 70 | var isQueueingEnabled = false 71 | 72 | if let preferences = qBittorrent.getSavedPreferences() { isQueueingEnabled = preferences.queueing_enabled ?? false } 73 | 74 | if(isQueueingEnabled) { 75 | return AnyView(Section(header: Text("Queue")) { 76 | Button { qBittorrent.increasePriorityTorrents(hashes: [torrent.hash]) } 77 | label: { MenuControlsLabelView(text: "Move Up", icon: "arrow.up") } 78 | 79 | Button { qBittorrent.decreasePriorityTorrents(hashes: [torrent.hash]) } 80 | label: { MenuControlsLabelView(text: "Move Down", icon: "arrow.down") } 81 | }) 82 | } 83 | 84 | return AnyView(EmptyView()) 85 | } 86 | 87 | func torrentRowManageControls(torrent: Torrent) -> some View { 88 | let isTorrentPaused = qBittorrent.getState(state: torrent.state).contains("Paused") 89 | 90 | return Section(header: Text("Manage")) { 91 | Button { if isTorrentPaused { qBittorrent.resumeTorrent(hash: torrent.hash) } else { qBittorrent.pauseTorrent(hash: torrent.hash) } } 92 | label: { MenuControlsLabelView(text: isTorrentPaused ? "Resume" : "Pause", icon: isTorrentPaused ? "play" : "pause") } 93 | 94 | Button { qBittorrent.recheckTorrent(hash: torrent.hash) } 95 | label: { MenuControlsLabelView(text: "Recheck", icon: "magnifyingglass") } 96 | 97 | Button { qBittorrent.reannounceTorrent(hash: torrent.hash) } 98 | label: { MenuControlsLabelView(text: "Reannounce", icon: "circle.dashed") } 99 | 100 | Button(role: .destructive) { viewModel.deleteTorrent(torrent: torrent) } 101 | label: { MenuControlsLabelView(text: "Delete", icon: "trash") } 102 | } 103 | } 104 | 105 | func torrentRowContextMenu(torrent: Torrent) -> some View { 106 | VStack { 107 | torrentRowQueueControls(torrent: torrent) 108 | torrentRowManageControls(torrent: torrent) 109 | } 110 | } 111 | 112 | 113 | // Alerts 114 | 115 | func deleteAlertView() -> some View { 116 | Group { 117 | Button("Delete Torrent", role: .destructive) { 118 | presentationMode.wrappedValue.dismiss() 119 | qBittorrent.deleteTorrent(hash: viewModel.hash) 120 | viewModel.hash = "" 121 | } 122 | Button("Delete Task with Files", role: .destructive) { 123 | presentationMode.wrappedValue.dismiss() 124 | qBittorrent.deleteTorrent(hash: viewModel.hash, deleteFiles: true) 125 | viewModel.hash = "" 126 | } 127 | Button("Cancel", role: .cancel) {} 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/TorrentListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggedInView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | 9 | struct TorrentListView: View { 10 | @StateObject var torrentListHelperViewModel: TorrentListHelperViewModel = .init() 11 | @State private var isFilterView = false 12 | @State private var torrentUrls: [URL] = [] 13 | 14 | func openUrl(url: URL) { 15 | if url.absoluteString.contains("file") || url.absoluteString.contains("magnet") { 16 | torrentListHelperViewModel.isTorrentAddView = true 17 | torrentUrls.append(url) 18 | } 19 | } 20 | 21 | var body: some View { 22 | NavigationStack { 23 | List { 24 | Section(header: Text("Manage")) { 25 | Button { torrentListHelperViewModel.isTorrentAddView.toggle() } 26 | label: { Label("Add Task", systemImage: "plus.circle") } 27 | .searchable(text: $torrentListHelperViewModel.searchQuery) 28 | } 29 | 30 | TorrentListHelperView(viewModel: torrentListHelperViewModel) 31 | .navigationTitle(torrentListHelperViewModel.category == "All" ? NSLocalizedString("Tasks", comment: "Tasks") : torrentListHelperViewModel.category.capitalized) 32 | } 33 | .toolbar() { 34 | TorrentListToolbar(torrents: $torrentListHelperViewModel.torrents, category: $torrentListHelperViewModel.category, isSelectionMode: $torrentListHelperViewModel.isSelectionMode, isFilterView: $isFilterView, selectedTorrents: $torrentListHelperViewModel.selectedTorrents) 35 | } 36 | .sheet(isPresented: $isFilterView, content: { 37 | FiltersMenuView(sort: $torrentListHelperViewModel.sort, reverse: $torrentListHelperViewModel.reverse, filter: $torrentListHelperViewModel.filter, category: $torrentListHelperViewModel.category, tag: $torrentListHelperViewModel.tag) 38 | }) 39 | .sheet(isPresented: $torrentListHelperViewModel.isTorrentAddView, content: { 40 | TorrentAddView(torrentUrls: $torrentUrls) 41 | }) 42 | .onOpenURL(perform: openUrl) 43 | } 44 | } 45 | } 46 | 47 | 48 | -------------------------------------------------------------------------------- /qBitControl/Views/TorrentViews/TrackersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsTrackersView.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct TrackersView: View { 9 | @ObservedObject var viewModel: TrackersViewModel 10 | 11 | init(viewModel: TrackersViewModel) { 12 | self.viewModel = viewModel 13 | } 14 | 15 | var body: some View { 16 | VStack { 17 | List { 18 | Section(header: Text("Manage")) { 19 | Button { 20 | viewModel.showAddTrackerPopover() 21 | } label: { 22 | Label("Add Tracker", systemImage: "plus.circle") 23 | } 24 | } 25 | 26 | Section(header: Text("\($viewModel.trackers.count)" + " " + NSLocalizedString("Trackers", comment: ""))) { 27 | ForEach($viewModel.trackers, id: \.url) { tracker in 28 | TrackerRow(tracker: tracker) 29 | .contextMenu { 30 | if !["** [DHT] **", "** [PeX] **", "** [LSD] **"].contains(tracker.wrappedValue.url) { 31 | Button { 32 | viewModel.showEditTrackerPopover(tracker: tracker.wrappedValue) 33 | } label: { 34 | Label("Edit", systemImage: "pencil") 35 | } 36 | 37 | Button(role: .destructive) { 38 | viewModel.removeTracker(tracker: tracker.wrappedValue) 39 | } label: { 40 | Label("Remove", systemImage: "trash") 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | .navigationTitle("Trackers") 49 | }.onAppear() { 50 | viewModel.setRefreshTimer() 51 | }.onDisappear() { 52 | viewModel.removeRefreshTimer() 53 | }.alert("Edit Tracker", isPresented: $viewModel.isEditTrackerAlert, actions: { 54 | VStack { 55 | TextField("New URL", text: $viewModel.newURL) 56 | Button("Save") { 57 | viewModel.editTracker() 58 | } 59 | Button("Cancel", role: .cancel) {} 60 | } 61 | }).alert("Add Tracker", isPresented: $viewModel.isAddTrackerAlert, actions: { 62 | TextField("URL", text: $viewModel.newURL) 63 | Button("Add") { 64 | viewModel.addTracker() 65 | } 66 | Button("Cancel", role: .cancel) { viewModel.isAddTrackerAlert = false } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /qBitControl/qBitControl.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /qBitControl/qBitControlApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // qBitControltApp.swift 3 | // qBitControl 4 | // 5 | 6 | import SwiftUI 7 | 8 | @main 9 | struct qBitControlApp: App { 10 | var body: some Scene { 11 | WindowGroup { 12 | MainView() 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /qBitControl/qBitControlTests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | -------------------------------------------------------------------------------- /qBitControl/qBitControlUITests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | -------------------------------------------------------------------------------- /qBitControlUITests/qBitControlUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import XCTest 4 | 5 | final class qBitControlUITests: XCTestCase { 6 | 7 | override func setUpWithError() throws { 8 | // Put setup code here. This method is called before the invocation of each test method in the class. 9 | 10 | // In UI tests it is usually best to stop immediately when a failure occurs. 11 | continueAfterFailure = false 12 | 13 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | // Put teardown code here. This method is called after the invocation of each test method in the class. 18 | } 19 | 20 | @MainActor 21 | func testExample() throws { 22 | // UI tests must launch the application that they test. 23 | let app = XCUIApplication() 24 | app.launch() 25 | 26 | // Use XCTAssert and related functions to verify your tests produce the correct results. 27 | } 28 | 29 | @MainActor 30 | func testLaunchPerformance() throws { 31 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 32 | // This measures how long it takes to launch your application. 33 | measure(metrics: [XCTApplicationLaunchMetric()]) { 34 | XCUIApplication().launch() 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /qBitControlUITests/qBitControlUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import XCTest 4 | 5 | final class qBitControlUITestsLaunchTests: XCTestCase { 6 | 7 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 8 | true 9 | } 10 | 11 | override func setUpWithError() throws { 12 | continueAfterFailure = false 13 | } 14 | 15 | @MainActor 16 | func testLaunch() throws { 17 | let app = XCUIApplication() 18 | app.launch() 19 | 20 | // Insert steps here to perform after app launch but before taking a screenshot, 21 | // such as logging into a test account or navigating somewhere in the app 22 | 23 | let attachment = XCTAttachment(screenshot: app.screenshot()) 24 | attachment.name = "Launch Screen" 25 | attachment.lifetime = .keepAlways 26 | add(attachment) 27 | } 28 | } 29 | --------------------------------------------------------------------------------