Files
plezy/lib/services/trackers/tracker_http_client.dart
edde746 04d8070fd4 refactor: pin the look-alike code paths that must not be merged
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
2026-07-26 06:09:47 +02:00

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;
}
}
}