Skip to content
Merged
2 changes: 2 additions & 0 deletions services/service_qbittorrent/lib/service_qbittorrent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ library;

export 'src/add_torrent_sheet.dart';
export 'src/models/qbit_detail.dart';
export 'src/models/qbit_log_entry.dart';
export 'src/models/qbit_torrent.dart';
export 'src/models/qbit_transfer_info.dart';
export 'src/qbittorrent_client.dart';
export 'src/qbittorrent_filter_drawer.dart';
export 'src/qbittorrent_home.dart';
export 'src/qbittorrent_logs_tab.dart';
export 'src/qbittorrent_providers.dart';
export 'src/torrent_detail_screen.dart';
79 changes: 79 additions & 0 deletions services/service_qbittorrent/lib/src/models/qbit_log_entry.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/// Severity levels for qBittorrent log entries.
enum QbitLogLevel {
normal('Normal'),
info('Info'),
warning('Warning'),
critical('Critical');

const QbitLogLevel(this.label);
final String label;

static QbitLogLevel fromType(int type) {
return switch (type) {
1 => QbitLogLevel.normal,
2 => QbitLogLevel.info,
4 => QbitLogLevel.warning,
8 => QbitLogLevel.critical,
_ => QbitLogLevel.normal,
};
}
}

/// A single application log message from `GET /api/v2/log/main`.
class QbitLogEntry {
const QbitLogEntry({
required this.id,
required this.message,
required this.timestamp,
required this.type,
});

/// The unique ID of the message.
final int id;

/// The content of the log entry.
final String message;

/// When the message was logged, in seconds since the epoch. qBittorrent
/// sent milliseconds before 4.5.0, so [dateTime] reads either.
final int timestamp;

/// Message type: 1 = normal, 2 = info, 4 = warning, 8 = critical.
final int type;

/// Parsed severity level.
QbitLogLevel get level => QbitLogLevel.fromType(type);

/// Normalized [DateTime] from [timestamp].
DateTime get dateTime {
if (timestamp > 100000000000) {
return DateTime.fromMillisecondsSinceEpoch(timestamp);
}
return DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
}

/// Formatted `HH:mm:ss` local time string.
String get timeText {
final DateTime local = dateTime.toLocal();
final String h = local.hour.toString().padLeft(2, '0');
final String m = local.minute.toString().padLeft(2, '0');
final String s = local.second.toString().padLeft(2, '0');
return '$h:$m:$s';
}

factory QbitLogEntry.fromJson(Map<String, dynamic> json) {
return QbitLogEntry(
id: json['id'] as int? ?? 0,
message: json['message'] as String? ?? '',
timestamp: json['timestamp'] as int? ?? 0,
type: json['type'] as int? ?? 1,
);
}

Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'message': message,
'timestamp': timestamp,
'type': type,
};
}
42 changes: 37 additions & 5 deletions services/service_qbittorrent/lib/src/qbittorrent_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:dio/io.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';

import 'models/qbit_detail.dart';
import 'models/qbit_log_entry.dart';
import 'models/qbit_torrent.dart';
import 'models/qbit_transfer_info.dart';

Expand Down Expand Up @@ -596,7 +597,7 @@ class QbittorrentClient {
_guarded(() async {
try {
final Response<dynamic> res =
await _dio.get<dynamic>('api/v2/app/networkInterfacesList');
await _dio.get<dynamic>('api/v2/app/networkInterfaceList');
dynamic raw = res.data;
if (raw is String && raw.isNotEmpty) {
try {
Expand Down Expand Up @@ -628,10 +629,10 @@ class QbittorrentClient {
_guarded(() async {
try {
final Response<dynamic> res = await _dio.get<dynamic>(
'api/v2/app/networkInterfaceAddressesList',
queryParameters: iface != null && iface.isNotEmpty
? <String, dynamic>{'iface': iface}
: null,
'api/v2/app/networkInterfaceAddressList',
// Sent even when empty: qBittorrent answers 400 without it, and
// an empty value is how every address is asked for.
queryParameters: <String, dynamic>{'iface': iface ?? ''},
);
dynamic raw = res.data;
if (raw is String && raw.isNotEmpty) {
Expand Down Expand Up @@ -675,6 +676,37 @@ class QbittorrentClient {
}
});

/// Retrieves application logs (`GET /api/v2/log/main`).
Future<List<QbitLogEntry>> getLogs({
bool normal = true,
bool info = true,
bool warning = true,
bool critical = true,
int lastKnownId = -1,
}) =>
_guarded(() async {
final Response<dynamic> resp = await _dio.get<dynamic>(
'api/v2/log/main',
queryParameters: <String, dynamic>{
'normal': normal,
'info': info,
'warning': warning,
'critical': critical,
if (lastKnownId >= 0) 'last_known_id': lastKnownId,
},
);
final dynamic data = resp.data;
if (data is! List) return const <QbitLogEntry>[];
return data
.map(
(dynamic item) => item is Map
? QbitLogEntry.fromJson(Map<String, dynamic>.from(item))
: null,
)
.whereType<QbitLogEntry>()
.toList();
});

/// Ensures a session exists, runs [call], and re-logins once on a 403.
Future<T> _guarded<T>(Future<T> Function() call) async {
// API-key auth is stateless: no login round-trip, and a 403 means the key
Expand Down
44 changes: 43 additions & 1 deletion services/service_qbittorrent/lib/src/qbittorrent_home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'models/qbit_transfer_info.dart';
import 'qbittorrent_action_utils.dart';
import 'qbittorrent_client.dart';
import 'qbittorrent_filter_drawer.dart';
import 'qbittorrent_logs_tab.dart';
import 'qbittorrent_providers.dart';
import 'qbittorrent_settings_tab.dart';
import 'torrent_detail_screen.dart';
Expand All @@ -38,17 +39,23 @@ class QbittorrentHome extends ConsumerWidget {
final int currentIndex = ref.watch(qbitActiveTabBarIndexProvider(instance));
final bool isNavbarVisible =
ref.watch(qbitBottomNavVisibleProvider(instance));
final bool isHomeTab = currentIndex == 0;

final List<Widget> tabs = <Widget>[
_TorrentsTab(instance: instance),
QbittorrentSettingsTab(instance: instance),
_BuiltOnceSelected(
selected: currentIndex == 2,
child: QbittorrentLogsTab(instance: instance),
),
];

return Scaffold(
drawerEdgeDragWidth:
drawer != null ? MediaQuery.sizeOf(context).width * 0.15 : null,
drawer: drawer,
endDrawer: QbittorrentFilterDrawer(instance: instance),
endDrawer: isHomeTab ? QbittorrentFilterDrawer(instance: instance) : null,
endDrawerEnableOpenDragGesture: isHomeTab,
body: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification.metrics.axis == Axis.vertical) {
Expand Down Expand Up @@ -167,6 +174,11 @@ class QbittorrentHome extends ConsumerWidget {
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
NavigationDestination(
icon: Icon(Icons.article_outlined),
selectedIcon: Icon(Icons.article),
label: 'Logs',
),
],
),
),
Expand All @@ -176,6 +188,36 @@ class QbittorrentHome extends ConsumerWidget {
}
}

/// Builds [child] the first time its tab is selected, and keeps it after.
///
/// [IndexedStack] builds every tab as soon as the screen opens, so a tab
/// that fetches while building fetches whether or not it is ever shown. For
/// the Logs tab that meant downloading qBittorrent's whole log, which can be
/// 20,000 entries, on every visit to the screen.
class _BuiltOnceSelected extends StatefulWidget {
const _BuiltOnceSelected({required this.selected, required this.child});

final bool selected;
final Widget child;

@override
State<_BuiltOnceSelected> createState() => _BuiltOnceSelectedState();
}

class _BuiltOnceSelectedState extends State<_BuiltOnceSelected> {
late bool _built = widget.selected;

@override
void didUpdateWidget(_BuiltOnceSelected oldWidget) {
super.didUpdateWidget(oldWidget);
_built = _built || widget.selected;
}

@override
Widget build(BuildContext context) =>
_built ? widget.child : const SizedBox.shrink();
}

class _TorrentsTab extends ConsumerStatefulWidget {
const _TorrentsTab({required this.instance});

Expand Down
Loading
Loading