├── .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 [](https://github.com/Michael-128/qBitControl/actions/workflows/automated-ios-build.yml) 
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 |
--------------------------------------------------------------------------------