Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
137 lines
5.2 KiB
Dart
137 lines
5.2 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 '../../utils/url_utils.dart';
|
|
import '../trackers/tracker_http_client.dart';
|
|
import 'seerr_constants.dart';
|
|
import 'seerr_exceptions.dart';
|
|
|
|
/// HTTP response paired with its decoded JSON body. `data` is null for
|
|
/// no-content responses and non-JSON bodies.
|
|
class SeerrResponse {
|
|
final http.Response response;
|
|
final dynamic data;
|
|
const SeerrResponse(this.response, this.data);
|
|
|
|
int get statusCode => response.statusCode;
|
|
}
|
|
|
|
/// Thin wrapper around `package:http` for Seerr API calls.
|
|
///
|
|
/// Adds the two things the tracker HTTP layer doesn't cover:
|
|
/// 1. `connect.sid` cookie capture from `Set-Cookie` on login, replayed as
|
|
/// `Cookie:` on every subsequent request — Express session auth.
|
|
/// 2. Query encoding via [encodeQueryParameters] (`%20` for spaces): Seerr
|
|
/// proxies `/search` to TMDB, which rejects `+` in the query value.
|
|
class SeerrHttpClient {
|
|
final String baseUrl;
|
|
final http.Client _http;
|
|
String? _cookie;
|
|
|
|
SeerrHttpClient({required String baseUrl, http.Client? httpClient, String? cookie})
|
|
: baseUrl = normalizeBaseUrl(baseUrl),
|
|
_http = httpClient ?? platform.createPlatformClient(),
|
|
_cookie = (cookie?.isNotEmpty ?? false) ? cookie : null;
|
|
|
|
/// Current `connect.sid` value (no `name=` prefix); null until a login
|
|
/// response is captured or [cookie] was seeded.
|
|
String? get cookie => _cookie;
|
|
|
|
set cookie(String? value) => _cookie = (value?.isNotEmpty ?? false) ? value : null;
|
|
|
|
void dispose() => _http.close();
|
|
|
|
/// Parse `Set-Cookie` from [response] and keep the `connect.sid` value.
|
|
/// Returns true when a cookie was captured.
|
|
///
|
|
/// `package:http` joins multiple `Set-Cookie` headers into one
|
|
/// comma-delimited string. Cookie values are URL-encoded and can't contain
|
|
/// a literal comma, so splitting on `,` and scanning each chunk for the
|
|
/// `connect.sid=` prefix is safe.
|
|
bool captureSessionCookie(http.Response response) {
|
|
final raw = response.headers['set-cookie'];
|
|
if (raw == null || raw.isEmpty) return false;
|
|
const prefix = '${SeerrConstants.sessionCookieName}=';
|
|
for (final chunk in raw.split(',')) {
|
|
final trimmed = chunk.trimLeft();
|
|
if (!trimmed.startsWith(prefix)) continue;
|
|
final afterName = trimmed.substring(prefix.length);
|
|
final end = afterName.indexOf(';');
|
|
final value = (end == -1 ? afterName : afterName.substring(0, end)).trim();
|
|
if (value.isEmpty) continue;
|
|
_cookie = value;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Send a request under [SeerrConstants.apiPath], returning the decoded
|
|
/// JSON body. 401 is returned to the caller (never thrown) so the client
|
|
/// can run its silent re-auth path.
|
|
Future<SeerrResponse> send(
|
|
String method,
|
|
String path, {
|
|
Map<String, Object?>? query,
|
|
Map<String, Object?>? body,
|
|
Duration timeout = SeerrConstants.requestTimeout,
|
|
bool authenticated = true,
|
|
}) async {
|
|
if (!const {'GET', 'POST', 'PUT', 'DELETE'}.contains(method)) {
|
|
throw ArgumentError('Unsupported HTTP method: $method');
|
|
}
|
|
final uri = _uri(path, query);
|
|
final headers = <String, String>{
|
|
'Accept': 'application/json',
|
|
if (authenticated && _cookie != null) 'Cookie': '${SeerrConstants.sessionCookieName}=$_cookie',
|
|
if (body != null) 'Content-Type': 'application/json',
|
|
};
|
|
final sw = Stopwatch()..start();
|
|
// Abortable so a timeout tears the request down instead of letting it
|
|
// race on — a timed-out POST /request must not land server-side after
|
|
// the UI already reported failure.
|
|
final response = await sendAbortableHttpRequest(
|
|
_http,
|
|
method,
|
|
uri,
|
|
headers: headers,
|
|
body: body == null ? null : jsonEncode(body),
|
|
timeout: timeout,
|
|
operation: 'Seerr $method $path',
|
|
);
|
|
appLogger.d('Seerr $method $path -> ${response.statusCode} (${sw.elapsedMilliseconds}ms)');
|
|
return SeerrResponse(response, TrackerHttpClient.decodeJson(response.body));
|
|
}
|
|
|
|
Uri _uri(String path, Map<String, Object?>? query) {
|
|
final base = Uri.parse('$baseUrl${SeerrConstants.apiPath}$path');
|
|
final encoded = encodeQueryParameters(query);
|
|
return encoded.isEmpty ? base : base.replace(query: encoded);
|
|
}
|
|
|
|
/// Throw the mapped exception for a 4xx/5xx response; no-op on success.
|
|
/// 401 is the caller's re-auth signal and also passes through.
|
|
static void throwForStatus(SeerrResponse res) {
|
|
final code = res.statusCode;
|
|
if (code >= 200 && code < 300 || code == 401) return;
|
|
final data = res.data;
|
|
final message = data is Map<String, dynamic> ? data['message'] as String? : null;
|
|
throw SeerrApiException((message?.isNotEmpty ?? false) ? message! : 'HTTP $code', statusCode: code);
|
|
}
|
|
|
|
/// Trim whitespace and trailing slashes so cookie/session identity and
|
|
/// request URLs agree on one canonical instance URL.
|
|
static String normalizeBaseUrl(String input) {
|
|
var v = input.trim();
|
|
while (v.endsWith('/')) {
|
|
v = v.substring(0, v.length - 1);
|
|
}
|
|
return v;
|
|
}
|
|
}
|