Several pairs of near-identical code paths differ in one load-bearing line. Each site now carries a comment naming the invariant that forces it apart, backed by a characterization test so a future deduplication fails loudly instead of silently changing behaviour. Pinned: focusable wrapper vs. chip D-pad activation policy, profile connection cleanup's raw-id vs. ServerId-typed server projections, live TV tab loaders, video player display matching and playback service wiring, track selection container ordering, tracker HTTP client status ladder, and the MediaServerHttpClient shutdown/cancellation contract versus ManagedHttpClient's closing guard. New tests: test/focus/dpad_activation_policy_test.dart test/services/track_selection_container_ordinal_test.dart test/services/trackers/tracker_status_ladder_test.dart test/utils/media_server_http_client_shutdown_test.dart
123 lines
3.7 KiB
Dart
123 lines
3.7 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../../utils/abortable_http_request.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import '../../utils/platform_http_client_stub.dart'
|
|
if (dart.library.io) '../../utils/platform_http_client_io.dart'
|
|
as platform;
|
|
import 'tracker_constants.dart';
|
|
|
|
/// Transport shared by the tracker clients: builds, times and logs a request,
|
|
/// then hands back the raw response.
|
|
///
|
|
/// Status handling stays with each client because the rules genuinely differ:
|
|
/// MAL and Simkl accept any 2xx, Trakt a per-call set (200/201/204, plus 409
|
|
/// for scrobble), AniList only 200 (GraphQL errors ride a 200 body); and a 401
|
|
/// means refresh-and-retry for Trakt/MAL but a terminal session for AniList
|
|
/// and Simkl.
|
|
class TrackerHttpClient {
|
|
static const Set<String> allMethods = {'GET', 'POST', 'PATCH', 'PUT', 'DELETE'};
|
|
|
|
final TrackerService service;
|
|
final String logLabel;
|
|
final http.Client _http;
|
|
|
|
TrackerHttpClient({required this.service, required this.logLabel, http.Client? httpClient})
|
|
: _http = httpClient ?? platform.createPlatformClient();
|
|
|
|
void dispose() => _http.close();
|
|
|
|
Future<http.Response> send(
|
|
String method,
|
|
Uri uri, {
|
|
Map<String, String>? headers,
|
|
Object? body,
|
|
Duration timeout = TrackerConstants.requestTimeout,
|
|
Set<String> allowedMethods = allMethods,
|
|
String? operation,
|
|
}) async {
|
|
if (!allowedMethods.contains(method)) {
|
|
throw ArgumentError('Unsupported HTTP method: $method');
|
|
}
|
|
|
|
final op = operation ?? '$logLabel $method ${uri.path}';
|
|
final sw = Stopwatch()..start();
|
|
final res = await sendAbortableHttpRequest(
|
|
_http,
|
|
method,
|
|
uri,
|
|
headers: headers,
|
|
body: body,
|
|
timeout: timeout,
|
|
operation: op,
|
|
);
|
|
sw.stop();
|
|
appLogger.d('$op -> ${res.statusCode} (${sw.elapsedMilliseconds}ms)');
|
|
return res;
|
|
}
|
|
|
|
Future<http.Response> sendJson(
|
|
String method,
|
|
Uri uri, {
|
|
Map<String, String>? headers,
|
|
Map<String, dynamic>? body,
|
|
Duration timeout = TrackerConstants.requestTimeout,
|
|
Set<String> allowedMethods = allMethods,
|
|
String? operation,
|
|
}) {
|
|
return send(
|
|
method,
|
|
uri,
|
|
// A JSON body must declare its content type; without this, package:http
|
|
// treats a String body as text/plain.
|
|
headers: body == null ? headers : _withJsonContentType(headers),
|
|
body: body == null ? null : json.encode(body),
|
|
timeout: timeout,
|
|
allowedMethods: allowedMethods,
|
|
operation: operation,
|
|
);
|
|
}
|
|
|
|
static Map<String, String> _withJsonContentType(Map<String, String>? headers) {
|
|
final merged = Map<String, String>.from(headers ?? const {});
|
|
final hasContentType = merged.keys.any((key) => key.toLowerCase() == 'content-type');
|
|
if (!hasContentType) merged['Content-Type'] = 'application/json';
|
|
return merged;
|
|
}
|
|
|
|
Future<http.Response> sendForm(
|
|
String method,
|
|
Uri uri, {
|
|
required Map<String, String> headers,
|
|
required Map<String, String> body,
|
|
Duration timeout = TrackerConstants.requestTimeout,
|
|
Set<String> allowedMethods = allMethods,
|
|
String? operation,
|
|
}) {
|
|
final formHeaders = Map<String, String>.from(headers)..['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
final encoded = body.entries
|
|
.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
|
|
.join('&');
|
|
return send(
|
|
method,
|
|
uri,
|
|
headers: formHeaders,
|
|
body: encoded,
|
|
timeout: timeout,
|
|
allowedMethods: allowedMethods,
|
|
operation: operation,
|
|
);
|
|
}
|
|
|
|
static dynamic decodeJson(String body) {
|
|
if (body.isEmpty) return null;
|
|
try {
|
|
return json.decode(body);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|