summaryrefslogtreecommitdiff
path: root/player-android/lib/api/dio_player_api_client.dart
diff options
context:
space:
mode:
Diffstat (limited to 'player-android/lib/api/dio_player_api_client.dart')
-rw-r--r--player-android/lib/api/dio_player_api_client.dart275
1 files changed, 275 insertions, 0 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart
new file mode 100644
index 0000000..6de1954
--- /dev/null
+++ b/player-android/lib/api/dio_player_api_client.dart
@@ -0,0 +1,275 @@
+import 'dart:typed_data';
+
+import 'package:dio/dio.dart';
+
+import '../models/models.dart';
+import 'player_api_client.dart';
+
+// Base path prefix used by all versioned API endpoints.
+const _kApiV1 = '/api/v1';
+
+/// Concrete [PlayerApiClient] implementation that delegates every call to the
+/// [Dio] instance supplied at construction time.
+///
+/// All HTTP details (auth header injection, 401 redirect) are already handled
+/// by the interceptors wired into [dio] — this class is concerned only with
+/// mapping routes and JSON to/from typed Dart values (Single Responsibility).
+///
+/// The class is intentionally thin: it does no caching, no retry logic, and no
+/// business rules. Higher-level constructs (Riverpod notifiers, use-cases) are
+/// responsible for those concerns (Separation of Concerns / ISP).
+class DioPlayerApiClient extends PlayerApiClient {
+ /// Constructs the client.
+ ///
+ /// In production [dio] should come from [DioClient] which adds bearer-token
+ /// injection and 401→login redirect interceptors. In tests, pass a plain or
+ /// mock [Dio] to keep tests fast and hermetic.
+ DioPlayerApiClient({required super.dio});
+
+ // ---------------------------------------------------------------------------
+ // Auth
+ // ---------------------------------------------------------------------------
+
+ /// Creates the first admin account (bootstrap flow).
+ ///
+ /// POST /api/v1/auth/bootstrap
+ /// Returns a [User] and sets a session cookie on the Dio CookieJar (if any).
+ @override
+ Future<User> bootstrap({
+ required String username,
+ required String password,
+ }) async {
+ final response = await rawDio.post<Map<String, dynamic>>(
+ '$_kApiV1/auth/bootstrap',
+ data: {'username': username, 'password': password},
+ );
+ return User.fromJson(response.data!);
+ }
+
+ /// Authenticates with username/password and returns the logged-in [User].
+ ///
+ /// POST /api/v1/auth/login
+ /// Sets a session cookie for subsequent cookie-authenticated requests.
+ @override
+ Future<User> login({
+ required String username,
+ required String password,
+ }) async {
+ final response = await rawDio.post<Map<String, dynamic>>(
+ '$_kApiV1/auth/login',
+ data: {'username': username, 'password': password},
+ );
+ return User.fromJson(response.data!);
+ }
+
+ /// Invalidates the current session cookie.
+ ///
+ /// POST /api/v1/logout — returns 204 No Content.
+ /// Bearer-authenticated clients should revoke the token directly instead.
+ @override
+ Future<void> logout() async {
+ await rawDio.post<void>('$_kApiV1/logout');
+ }
+
+ // ---------------------------------------------------------------------------
+ // Health
+ // ---------------------------------------------------------------------------
+
+ /// Liveness probe — returns immediately without touching the database.
+ ///
+ /// GET /healthz — 200 means the server process is alive.
+ @override
+ Future<void> healthz() async {
+ // Health endpoints sit outside the /api/v1/ prefix by convention.
+ await rawDio.get<void>('/healthz');
+ }
+
+ /// Readiness probe — pings the database and returns 503 if unavailable.
+ ///
+ /// GET /readyz — 200 means the server can serve traffic.
+ @override
+ Future<void> readyz() async {
+ await rawDio.get<void>('/readyz');
+ }
+
+ // ---------------------------------------------------------------------------
+ // Sets
+ // ---------------------------------------------------------------------------
+
+ /// Returns all sets visible to the authenticated user.
+ ///
+ /// GET /api/v1/sets
+ @override
+ Future<List<MediaSet>> listSets() async {
+ final response = await rawDio.get<List<dynamic>>('$_kApiV1/sets');
+ return (response.data ?? [])
+ .cast<Map<String, dynamic>>()
+ .map(MediaSet.fromJson)
+ .toList();
+ }
+
+ /// Browses the folder tree within a set, optionally scoped to [parent].
+ ///
+ /// GET /api/v1/sets/{id}/browse?parent=...
+ /// Returns the raw JSON map because the response shape (folders, media,
+ /// episodes) is context-dependent and has no single model counterpart yet.
+ @override
+ Future<Map<String, dynamic>> browseSet(int setId, {String? parent}) async {
+ final response = await rawDio.get<Map<String, dynamic>>(
+ '$_kApiV1/sets/$setId/browse',
+ queryParameters: {if (parent != null) 'parent': parent},
+ );
+ return response.data ?? {};
+ }
+
+ // ---------------------------------------------------------------------------
+ // Media
+ // ---------------------------------------------------------------------------
+
+ /// Lists or searches media visible to the authenticated user.
+ ///
+ /// GET /api/v1/media — supports a rich set of query parameters for filtering,
+ /// sorting, and pagination. All parameters are optional.
+ @override
+ Future<List<Media>> listMedia({
+ String? search,
+ int? setId,
+ List<int>? setIds,
+ String? type,
+ bool? favorites,
+ List<String>? tags,
+ double? minDuration,
+ double? maxDuration,
+ int? fileSizeMin,
+ int? fileSizeMax,
+ String? sort,
+ int? limit,
+ int? offset,
+ String? folder,
+ String? parent,
+ }) async {
+ // Build the query-parameter map, omitting null values so they are not sent.
+ final params = <String, dynamic>{
+ if (search != null) 'search': search,
+ if (setId != null) 'set_id': setId,
+ if (setIds != null && setIds.isNotEmpty)
+ // The server expects a comma-separated string for set_ids.
+ 'set_ids': setIds.join(','),
+ if (type != null) 'type': type,
+ if (favorites != null) 'favorites': favorites ? 'true' : 'false',
+ if (tags != null && tags.isNotEmpty) 'tags': tags.join(','),
+ if (minDuration != null) 'min_duration': minDuration,
+ if (maxDuration != null) 'max_duration': maxDuration,
+ if (fileSizeMin != null) 'filesize_min': fileSizeMin,
+ if (fileSizeMax != null) 'filesize_max': fileSizeMax,
+ if (sort != null) 'sort': sort,
+ if (limit != null) 'limit': limit,
+ if (offset != null) 'offset': offset,
+ if (folder != null) 'folder': folder,
+ if (parent != null) 'parent': parent,
+ };
+
+ final response = await rawDio.get<List<dynamic>>(
+ '$_kApiV1/media',
+ queryParameters: params,
+ );
+ return (response.data ?? [])
+ .cast<Map<String, dynamic>>()
+ .map(Media.fromJson)
+ .toList();
+ }
+
+ /// Returns a single media item including tags, favorite state, note, and
+ /// saved playback progress.
+ ///
+ /// GET /api/v1/media/{id}
+ /// The server envelope wraps the media object; this method unwraps it so
+ /// callers receive a plain [Media].
+ @override
+ Future<Media> getMedia(int mediaId) async {
+ final response = await rawDio.get<Map<String, dynamic>>(
+ '$_kApiV1/media/$mediaId',
+ );
+
+ // Guard against a null or structurally unexpected response body. In
+ // practice Dio raises a DioException before we get here, but being
+ // defensive avoids a crash if the server sends an empty 200.
+ final envelope = response.data ?? {};
+
+ // The API returns {"media": {...}, "tags": [...], "favorite": bool, ...}.
+ // Merge the top-level `tags` list and `favorite` flag into the nested media
+ // map before deserialising so Media.fromJson picks them up correctly.
+ final rawMedia = envelope['media'];
+ final mediaMap = rawMedia is Map<String, dynamic>
+ ? Map<String, dynamic>.from(rawMedia)
+ : <String, dynamic>{};
+
+ // Inject the per-user fields from the envelope into the media map.
+ final rawTags = envelope['tags'];
+ if (rawTags is List) {
+ // Tags are returned as [{id, name}, ...]; extract the name strings.
+ mediaMap['tags'] =
+ rawTags.cast<Map<String, dynamic>>().map((t) => t['name']).toList();
+ }
+
+ if (envelope['favorite'] is bool) {
+ mediaMap['favorite'] = envelope['favorite'] as bool;
+ }
+
+ return Media.fromJson(mediaMap);
+ }
+
+ /// Streams a media file, optionally from a byte [range] offset.
+ ///
+ /// GET /api/v1/media/{id}/stream
+ /// Supports the standard HTTP Range header for seeking. Returns the raw bytes
+ /// so the caller can feed them to a local file or a video player.
+ @override
+ Future<Uint8List> streamMedia(int mediaId, {String? range}) {
+ // Only set the Range header when a range is actually requested; an empty
+ // headers map is harmless but adds noise to the request.
+ final extraHeaders =
+ range != null ? <String, dynamic>{'Range': range} : null;
+ return _getBytesFromUrl(
+ '$_kApiV1/media/$mediaId/stream',
+ extraHeaders: extraHeaders,
+ );
+ }
+
+ /// Downloads the original media file with Content-Disposition: attachment.
+ ///
+ /// GET /api/v1/media/{id}/download
+ @override
+ Future<Uint8List> downloadMedia(int mediaId) =>
+ _getBytesFromUrl('$_kApiV1/media/$mediaId/download');
+
+ /// Returns the JPEG thumbnail for a media item.
+ ///
+ /// GET /api/v1/media/{id}/thumbnail
+ @override
+ Future<Uint8List> getThumbnail(int mediaId) =>
+ _getBytesFromUrl('$_kApiV1/media/$mediaId/thumbnail');
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ /// Issues a GET request with [ResponseType.bytes] and returns the response
+ /// body as a [Uint8List].
+ ///
+ /// Shared by [streamMedia], [downloadMedia], and [getThumbnail] to avoid
+ /// repeating the same byte-response boilerplate in each method.
+ Future<Uint8List> _getBytesFromUrl(
+ String path, {
+ Map<String, dynamic>? extraHeaders,
+ }) async {
+ final response = await rawDio.get<List<int>>(
+ path,
+ options: Options(
+ responseType: ResponseType.bytes,
+ headers: extraHeaders,
+ ),
+ );
+ return Uint8List.fromList(response.data ?? []);
+ }
+}