Implement qBittorrent download management via WebUI API

This commit is contained in:
Zhang Peter 2026-01-04 17:49:53 +08:00
parent 1b62a7c6e8
commit 26bbacffd2
2 changed files with 528 additions and 96 deletions

View File

@ -1,8 +1,151 @@
import 'package:flutter/material.dart';
import '../services/qbittorrent_client.dart';
class DownloadPage extends StatelessWidget {
class DownloadPage extends StatefulWidget {
const DownloadPage({super.key});
@override
State<DownloadPage> createState() => _DownloadPageState();
}
class _DownloadPageState extends State<DownloadPage> {
List<Map<String, dynamic>> _torrents = [];
List<Map<String, dynamic>> _filteredTorrents = [];
bool _isLoading = false;
String _selectedCategory = '全部';
String _downloadSpeed = '0.0 MB/s';
String _uploadSpeed = '0.0 MB/s';
@override
void initState() {
super.initState();
_loadTorrents();
}
Future<void> _loadTorrents() async {
setState(() {
_isLoading = true;
});
final torrents = await QBittorrentClient.getTorrents();
setState(() {
_torrents = torrents;
_filterTorrents();
_calculateStats();
_isLoading = false;
});
}
void _filterTorrents() {
if (_selectedCategory == '全部') {
_filteredTorrents = _torrents;
} else if (_selectedCategory == '下载中') {
_filteredTorrents =
_torrents.where((torrent) {
final status = torrent['state'] as String;
return status.contains('downloading') ||
status.contains('queuedDL') ||
status.contains('checkingDL');
}).toList();
} else if (_selectedCategory == '已完成') {
_filteredTorrents =
_torrents.where((torrent) {
final status = torrent['state'] as String;
return status == 'pausedUP' ||
status == 'stalledUP' ||
status == 'uploading' ||
status == 'queuedUP';
}).toList();
}
}
void _calculateStats() {
double totalDownloadSpeed = 0;
double totalUploadSpeed = 0;
for (var torrent in _torrents) {
totalDownloadSpeed += (torrent['dlspeed'] as num).toDouble();
totalUploadSpeed += (torrent['upspeed'] as num).toDouble();
}
_downloadSpeed = _formatSpeed(totalDownloadSpeed);
_uploadSpeed = _formatSpeed(totalUploadSpeed);
}
String _formatSpeed(double speed) {
if (speed < 1024) {
return '${speed.toStringAsFixed(1)} B/s';
} else if (speed < 1024 * 1024) {
return '${(speed / 1024).toStringAsFixed(1)} KB/s';
} else {
return '${(speed / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
}
String _formatSize(int size) {
if (size < 1024) {
return '$size B';
} else if (size < 1024 * 1024) {
return '${(size / 1024).toStringAsFixed(1)} KB';
} else if (size < 1024 * 1024 * 1024) {
return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(size / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
String _getStatusText(String status) {
switch (status) {
case 'downloading':
return '下载中';
case 'queuedDL':
return '等待下载';
case 'checkingDL':
return '检查文件';
case 'pausedDL':
return '已暂停';
case 'stalledDL':
return '下载停滞';
case 'uploading':
return '上传中';
case 'queuedUP':
return '等待上传';
case 'checkingUP':
return '检查上传';
case 'pausedUP':
return '已完成';
case 'stalledUP':
return '上传停滞';
default:
return status;
}
}
Future<void> _toggleTorrent(String hash, bool isPaused) async {
bool success;
if (isPaused) {
success = await QBittorrentClient.resumeTorrent(hash);
} else {
success = await QBittorrentClient.pauseTorrent(hash);
}
if (success) {
await _loadTorrents();
}
}
Future<void> _deleteTorrent(String hash, bool deleteFiles) async {
final success = await QBittorrentClient.deleteTorrent(
hash,
deleteFiles: deleteFiles,
);
if (success) {
await _loadTorrents();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -13,7 +156,7 @@ class DownloadPage extends StatelessWidget {
actions: [
IconButton(
icon: const Icon(Icons.refresh_outlined, size: 24),
onPressed: () {},
onPressed: _loadTorrents,
color: const Color(0xFF1F2937),
),
],
@ -29,7 +172,7 @@ class DownloadPage extends StatelessWidget {
Expanded(
child: _buildStatCard(
'下载速度',
'0.0 MB/s',
_downloadSpeed,
Icons.arrow_downward,
const Color(0xFF3B82F6),
),
@ -38,7 +181,7 @@ class DownloadPage extends StatelessWidget {
Expanded(
child: _buildStatCard(
'上传速度',
'0.0 MB/s',
_uploadSpeed,
Icons.arrow_upward,
const Color(0xFF22C55E),
),
@ -47,7 +190,7 @@ class DownloadPage extends StatelessWidget {
Expanded(
child: _buildStatCard(
'任务数',
'0',
_torrents.length.toString(),
Icons.task_outlined,
const Color(0xFF8B5CF6),
),
@ -61,88 +204,28 @@ class DownloadPage extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 16.0),
child: Row(
children: [
_buildCategoryTab('全部', true),
_buildCategoryTab('下载中', false),
_buildCategoryTab('已完成', false),
_buildCategoryTab('全部', _selectedCategory == '全部'),
_buildCategoryTab('下载中', _selectedCategory == '下载中'),
_buildCategoryTab('已完成', _selectedCategory == '已完成'),
],
),
),
//
//
Expanded(
child: Container(
color: const Color(0xFFF8FAFC),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 120,
height: 120,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(
Icons.download_for_offline_outlined,
size: 64,
color: Color(0xFF9CA3AF),
),
),
),
const SizedBox(height: 24),
const Text(
'暂无下载任务',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 8),
Text(
'从首页推送资源到这里',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF6B7280),
),
),
const SizedBox(height: 32),
SizedBox(
height: 44,
child: ElevatedButton.icon(
onPressed: () {
//
// Navigator.pushReplacement(
// context,
// MaterialPageRoute(
// builder: (context) => const MainPage(),
// ),
// );
child:
_isLoading
? const Center(child: CircularProgressIndicator())
: _filteredTorrents.isEmpty
? _buildEmptyState()
: ListView.builder(
itemCount: _filteredTorrents.length,
itemBuilder: (context, index) {
final torrent = _filteredTorrents[index];
return _buildTorrentItem(torrent);
},
icon: const Icon(Icons.home, size: 16),
label: const Text('去首页推送'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF22C55E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
),
),
],
@ -150,6 +233,138 @@ class DownloadPage extends StatelessWidget {
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 120,
height: 120,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(
Icons.download_for_offline_outlined,
size: 64,
color: Color(0xFF9CA3AF),
),
),
),
const SizedBox(height: 24),
Text(
_selectedCategory == '全部' ? '暂无下载任务' : '该分类下暂无任务',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 8),
Text(
_selectedCategory == '全部' ? '从首页推送资源到这里' : '',
style: const TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
),
],
),
);
}
Widget _buildTorrentItem(Map<String, dynamic> torrent) {
final name = torrent['name'] as String;
final size = (torrent['size'] as num).toInt();
final progress = (torrent['progress'] as num).toDouble() * 100;
final state = torrent['state'] as String;
final isPaused = state.startsWith('paused') || state.contains('queued');
final dlspeed = (torrent['dlspeed'] as num).toDouble();
final upspeed = (torrent['upspeed'] as num).toDouble();
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE5E7EB), width: 1),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1F2937),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
Text(
'${_getStatusText(state)} · ${_formatSize(size)} · ${progress.toStringAsFixed(1)}%',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
const Spacer(),
if (dlspeed > 0 || upspeed > 0) ...[
Text(
'${_formatSpeed(dlspeed)} / ${_formatSpeed(upspeed)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: progress / 100,
backgroundColor: const Color(0xFFE5E7EB),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF22C55E),
),
borderRadius: BorderRadius.circular(12),
minHeight: 8,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
icon: Icon(
isPaused ? Icons.play_arrow : Icons.pause,
size: 20,
color: const Color(0xFF6B7280),
),
onPressed:
() => _toggleTorrent(torrent['hash'] as String, isPaused),
),
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: const Color(0xFFEF4444),
),
onPressed:
() => _deleteTorrent(torrent['hash'] as String, false),
),
],
),
],
),
),
);
}
Widget _buildStatCard(
String title,
String value,
@ -197,24 +412,33 @@ class DownloadPage extends StatelessWidget {
Widget _buildCategoryTab(String title, bool isActive) {
return Expanded(
child: Container(
margin: const EdgeInsets.only(right: 8.0),
height: 44,
decoration: BoxDecoration(
color: isActive ? const Color(0xFF22C55E) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isActive ? const Color(0xFF22C55E) : const Color(0xFFE5E7EB),
width: 1,
child: InkWell(
onTap: () {
setState(() {
_selectedCategory = title;
_filterTorrents();
});
},
child: Container(
margin: const EdgeInsets.only(right: 8.0),
height: 44,
decoration: BoxDecoration(
color: isActive ? const Color(0xFF22C55E) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
isActive ? const Color(0xFF22C55E) : const Color(0xFFE5E7EB),
width: 1,
),
),
),
child: Center(
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isActive ? Colors.white : Color(0xFF1F2937),
child: Center(
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isActive ? Colors.white : Color(0xFF1F2937),
),
),
),
),

View File

@ -0,0 +1,208 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class QBittorrentClient {
static Future<Map<String, String>?> _login() async {
try {
final prefs = await SharedPreferences.getInstance();
final host = prefs.getString('qbittorrent_host') ?? '';
final port = prefs.getString('qbittorrent_port') ?? '';
final username = prefs.getString('qbittorrent_username') ?? '';
final password = prefs.getString('qbittorrent_password') ?? '';
final useHttps = prefs.getBool('qbittorrent_use_https') ?? false;
print(
'qBittorrent配置: host=$host, port=$port, username=$username, useHttps=$useHttps',
);
if (host.isEmpty || port.isEmpty) {
print('qBittorrent配置不完整缺少host或port');
return null;
}
final scheme = useHttps ? 'https' : 'http';
final baseUrl = '$scheme://$host:$port';
final client = http.Client();
try {
// CSRF Token
print('尝试获取CSRF Token: $baseUrl/api/v2/app/version');
final response = await client.get(
Uri.parse('$baseUrl/api/v2/app/version'),
headers: {'Referer': baseUrl},
);
print(
'获取CSRF Token响应: statusCode=${response.statusCode}, headers=${response.headers}',
);
final cookies = response.headers['set-cookie'] ?? '';
print('获取到的cookies: $cookies');
final csrfToken =
cookies
.split(';')
.firstWhere(
(cookie) => cookie.startsWith('SID='),
orElse: () => '',
)
.split('=')
.last;
print('获取到的CSRF Token: $csrfToken');
if (csrfToken.isEmpty) {
print('CSRF Token获取失败为空');
return null;
}
//
print('尝试登录qBittorrent');
final loginResponse = await client.post(
Uri.parse('$baseUrl/api/v2/auth/login'),
headers: {'Referer': baseUrl, 'X-CSRF-Token': csrfToken},
body: {'username': username, 'password': password},
);
print(
'登录响应: statusCode=${loginResponse.statusCode}, body=${loginResponse.body}',
);
if (loginResponse.statusCode != 200 || loginResponse.body != 'Ok.') {
print(
'登录失败,状态码: ${loginResponse.statusCode}, 响应体: ${loginResponse.body}',
);
return null;
}
print('登录成功');
return {'baseUrl': baseUrl, 'cookie': cookies, 'csrfToken': csrfToken};
} finally {
client.close();
}
} catch (e, stackTrace) {
print('登录异常: $e');
print('异常堆栈: $stackTrace');
return null;
}
}
static Future<List<Map<String, dynamic>>> getTorrents() async {
final session = await _login();
if (session == null) {
print('登录失败,无法获取下载列表');
return [];
}
final client = http.Client();
final baseUrl = session['baseUrl']!;
final cookie = session['cookie']!;
final csrfToken = session['csrfToken']!;
try {
print('尝试获取下载列表: $baseUrl/api/v2/torrents/info');
final response = await client.get(
Uri.parse('$baseUrl/api/v2/torrents/info'),
headers: {
'Referer': baseUrl,
'X-CSRF-Token': csrfToken,
'Cookie': cookie,
},
);
print(
'获取下载列表响应: statusCode=${response.statusCode}, body=${response.body}',
);
if (response.statusCode == 200) {
final data = json.decode(response.body) as List;
print('获取到的下载列表数量: ${data.length}');
return data.cast<Map<String, dynamic>>();
}
print('获取下载列表失败,状态码: ${response.statusCode}');
return [];
} catch (e, stackTrace) {
print('获取下载列表异常: $e');
print('异常堆栈: $stackTrace');
return [];
} finally {
client.close();
}
}
static Future<bool> pauseTorrent(String hash) async {
return _torrentAction(hash, 'pause');
}
static Future<bool> resumeTorrent(String hash) async {
return _torrentAction(hash, 'resume');
}
static Future<bool> deleteTorrent(
String hash, {
bool deleteFiles = false,
}) async {
final session = await _login();
if (session == null) {
return false;
}
final client = http.Client();
final baseUrl = session['baseUrl']!;
final cookie = session['cookie']!;
final csrfToken = session['csrfToken']!;
try {
final response = await client.post(
Uri.parse('$baseUrl/api/v2/torrents/delete'),
headers: {
'Referer': baseUrl,
'X-CSRF-Token': csrfToken,
'Cookie': cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {'hashes': hash, 'deleteFiles': deleteFiles ? 'true' : 'false'},
);
return response.statusCode == 200;
} catch (e) {
print('删除任务失败: $e');
return false;
} finally {
client.close();
}
}
static Future<bool> _torrentAction(String hash, String action) async {
final session = await _login();
if (session == null) {
return false;
}
final client = http.Client();
final baseUrl = session['baseUrl']!;
final cookie = session['cookie']!;
final csrfToken = session['csrfToken']!;
try {
final response = await client.post(
Uri.parse('$baseUrl/api/v2/torrents/$action'),
headers: {
'Referer': baseUrl,
'X-CSRF-Token': csrfToken,
'Cookie': cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {'hashes': hash},
);
return response.statusCode == 200;
} catch (e) {
print('$action任务失败: $e');
return false;
} finally {
client.close();
}
}
}