93 lines
3.0 KiB
Dart
93 lines
3.0 KiB
Dart
import 'dart:async';
|
|
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';
|
|
import 'simkl_constants.dart';
|
|
import 'simkl_session.dart';
|
|
|
|
/// HTTP wrapper for the Simkl REST API.
|
|
///
|
|
/// Simkl tokens don't expire; a 401 is terminal (user revoked access at
|
|
/// simkl.com/settings/apps). [onSessionInvalidated] clears the local session
|
|
/// in that case.
|
|
class SimklClient {
|
|
final SimklSession session;
|
|
final http.Client _http;
|
|
final void Function() onSessionInvalidated;
|
|
|
|
SimklClient(this.session, {required this.onSessionInvalidated, http.Client? httpClient})
|
|
: _http = httpClient ?? platform.createPlatformClient();
|
|
|
|
void dispose() => _http.close();
|
|
|
|
/// Fetch current user info. Used to populate the display name.
|
|
Future<Map<String, dynamic>?> getUserSettings() async {
|
|
final res = await _request('GET', '/users/settings');
|
|
return res is Map ? res.cast<String, dynamic>() : null;
|
|
}
|
|
|
|
/// Mark one or more items as watched. Body shape:
|
|
/// ```
|
|
/// {"movies": [{"ids": {"simkl": 123}}], "shows": [...]}
|
|
/// ```
|
|
Future<void> addToHistory(Map<String, dynamic> body) => _request('POST', '/sync/history', body: body);
|
|
|
|
Future<dynamic> _request(String method, String path, {Map<String, dynamic>? body}) async {
|
|
final uri = Uri.parse('${SimklConstants.apiBase}$path');
|
|
final headers = SimklConstants.headers(accessToken: session.accessToken);
|
|
final encoded = body == null ? null : json.encode(body);
|
|
|
|
final sw = Stopwatch()..start();
|
|
final res = await switch (method) {
|
|
'GET' || 'POST' => sendAbortableHttpRequest(
|
|
_http,
|
|
method,
|
|
uri,
|
|
headers: headers,
|
|
body: encoded,
|
|
timeout: TrackerConstants.requestTimeout,
|
|
operation: 'Simkl $method ${uri.path}',
|
|
),
|
|
_ => throw ArgumentError('Unsupported HTTP method: $method'),
|
|
};
|
|
sw.stop();
|
|
appLogger.d('Simkl $method ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)');
|
|
|
|
if (res.statusCode == 401) {
|
|
onSessionInvalidated();
|
|
throw SimklAuthException('Session invalidated (401)');
|
|
}
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
throw SimklApiException(statusCode: res.statusCode, body: res.body);
|
|
}
|
|
if (res.body.isEmpty) return null;
|
|
try {
|
|
return json.decode(res.body);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
class SimklApiException implements Exception {
|
|
final int statusCode;
|
|
final String body;
|
|
const SimklApiException({required this.statusCode, required this.body});
|
|
@override
|
|
String toString() => 'SimklApiException(HTTP $statusCode): $body';
|
|
}
|
|
|
|
class SimklAuthException implements Exception {
|
|
final String message;
|
|
const SimklAuthException(this.message);
|
|
@override
|
|
String toString() => 'SimklAuthException: $message';
|
|
}
|