Files
jellyfin-plugin-floatplane/doc/research/reference-sources/floaty_fpapi.dart
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

1017 lines
32 KiB
Dart

import 'package:floaty/whitelabels.dart';
import 'package:floaty/features/api/models/definitions.dart';
import 'package:floaty/features/logs/repositories/log_service.dart';
import 'package:floaty/features/authentication/services/oauth2_service.dart';
import 'package:hive_ce/hive.dart';
import 'package:logging/logging.dart';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:http/http.dart' as http;
final FPApiRequests fpApiRequests = GetIt.I<FPApiRequests>();
/// Custom HTTP client wrapper that adds cookie support
class _CookieHttpClient extends http.BaseClient {
final http.Client _inner = http.Client();
final PersistCookieJar cookieJar;
_CookieHttpClient(this.cookieJar);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Add cookies from jar to request
final uri = request.url;
final cookies = await cookieJar.loadForRequest(uri);
if (cookies.isNotEmpty) {
final cookieHeader =
cookies.map((c) => '${c.name}=${c.value}').join('; ');
request.headers['cookie'] = cookieHeader;
}
// Send request
final response = await _inner.send(request);
// Save cookies from response
final setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders != null) {
final responseCookies = setCookieHeaders
.split(',')
.map((str) => Cookie.fromSetCookieValue(str))
.toList();
await cookieJar.saveFromResponse(uri, responseCookies);
}
return response;
}
@override
void close() {
_inner.close();
}
}
/// Custom HTTP client wrapper that adds OAuth2 Bearer token authentication
class _OAuth2HttpClient extends http.BaseClient {
final _CookieHttpClient _inner;
final OAuth2Service _oauth2Service;
final String _whitelabel;
_OAuth2HttpClient(this._inner, this._oauth2Service, this._whitelabel);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Get fresh access token (handles refresh if needed)
String? accessToken =
await _oauth2Service.getAccessToken(whitelabel: _whitelabel);
// Check if token is expired and refresh if needed
final isExpired =
await _oauth2Service.isTokenExpired(whitelabel: _whitelabel);
if (isExpired && accessToken != null) {
final refreshToken =
await _oauth2Service.getRefreshToken(whitelabel: _whitelabel);
if (refreshToken != null) {
final result = await _oauth2Service.refreshToken(refreshToken);
if (result.isSuccess) {
await _oauth2Service.storeTokens(result, whitelabel: _whitelabel);
accessToken = result.accessToken;
}
}
}
// Add Authorization header if we have a token
if (accessToken != null && accessToken.isNotEmpty) {
request.headers['authorization'] = 'Bearer $accessToken';
}
// Delegate to inner client (which handles cookies)
final response = await _inner.send(request);
return response;
}
@override
void close() {
// Don't close the inner client as it's shared across all OAuth2 clients
}
}
class FPApiRequests {
final Logger _log = Logger('FPApiRequests');
String userAgent = 'FloatyClient/error';
late final PersistCookieJar cookieJar;
late final OAuth2Service _oauth2Service;
final Map<String, http.Client> _oauth2Clients = {};
late final http.Client _fallbackClient;
late final _CookieHttpClient _cookieClient;
PackageInfo? packageInfo;
// Feature flag for cookie-based testing
bool get useCookieAuth =>
const bool.fromEnvironment('USE_COOKIE_AUTH', defaultValue: false);
/// Get the appropriate HTTP client for a specific whitelabel
Future<http.Client> getHttpClient(String whitelabel) async {
final authMethod =
await _oauth2Service.getAuthMethod(whitelabel: whitelabel);
final useCookie =
(authMethod == 'cookie') || (authMethod == null && useCookieAuth);
if (!useCookie) {
// Try to get or create OAuth2 client for this whitelabel
if (!_oauth2Clients.containsKey(whitelabel)) {
final client = await createOAuth2Client(whitelabel: whitelabel);
if (client != null) {
_oauth2Clients[whitelabel] = client;
}
}
if (_oauth2Clients.containsKey(whitelabel)) {
return _oauth2Clients[whitelabel]!;
}
}
// Fallback to cookie client
return _fallbackClient;
}
/// Get the appropriate HTTP client (OAuth2 or fallback with cookies)
/// This is kept for backwards compatibility but deprecated
@Deprecated('Use getHttpClient(whitelabel) instead')
http.Client get httpClient => _fallbackClient;
FPApiRequests() {
_init();
}
Future<void> _init() async {
packageInfo = await PackageInfo.fromPlatform();
const flavor =
String.fromEnvironment('FLUTTER_FLAVOR', defaultValue: 'release');
userAgent =
'FloatyClient/${packageInfo?.version}+${packageInfo?.buildNumber}-$flavor';
final dir = await getApplicationSupportDirectory();
_oauth2Service = OAuth2Service.instance;
cookieJar = PersistCookieJar(
storage: FileStorage('${dir.path}/.cookies/'),
);
// Create cookie HTTP client
_cookieClient = _CookieHttpClient(cookieJar);
// Create fallback HTTP client with cookie support
_fallbackClient = _cookieClient;
// Initialize OAuth2 clients for all logged-in whitelabels
if (!useCookieAuth) {
_initOAuth2Clients();
}
}
/// Initialize OAuth2 Clients for all logged-in whitelabels
Future<void> _initOAuth2Clients() async {
final loggedInLabels = await whitelabels.getLoggedInLabels();
for (final label in loggedInLabels) {
final whitelabelName = label.split('-')[0];
final authMethod =
await _oauth2Service.getAuthMethod(whitelabel: whitelabelName);
if (authMethod == 'oauth2') {
final client = await createOAuth2Client(whitelabel: whitelabelName);
if (client != null) {
_oauth2Clients[whitelabelName] = client;
}
}
}
}
/// Create or recreate OAuth2 client from stored credentials for a specific whitelabel
Future<http.Client?> createOAuth2Client({String? whitelabel}) async {
try {
final whitelabelName = whitelabel ?? 'default';
// if (kDebugMode) {
// print(
// '[FPApiRequests] createOAuth2Client() for whitelabel: $whitelabelName');
// }
final accessToken =
await _oauth2Service.getAccessToken(whitelabel: whitelabelName);
final refreshToken =
await _oauth2Service.getRefreshToken(whitelabel: whitelabelName);
if (accessToken == null || refreshToken == null) {
return null;
}
// Create a custom HTTP client that handles OAuth2 authentication and cookies
final client =
_OAuth2HttpClient(_cookieClient, _oauth2Service, whitelabelName);
return client;
} catch (e) {
return null;
}
}
/// Remove OAuth2 client from cache (e.g., on logout)
void clearOAuth2Client(String whitelabel) {
if (_oauth2Clients.containsKey(whitelabel)) {
_oauth2Clients[whitelabel]?.close();
_oauth2Clients.remove(whitelabel);
}
}
Future<String> postData(
String apiUrl,
String whitelabel, [
Map<String, dynamic>? body,
Map<String, dynamic>? queryParams,
]) async {
try {
final whiteLabel = whitelabels.getWhitelabel(whitelabel);
final client = await getHttpClient(whiteLabel.friendlyName);
var url = Uri.parse('${whiteLabel.apiUrl}/$apiUrl');
if (queryParams != null && queryParams.isNotEmpty) {
url = url.replace(queryParameters: queryParams);
}
final response = await client.post(
url,
headers: {'Content-Type': 'application/json', 'User-Agent': userAgent},
body: body != null ? jsonEncode(body) : null,
);
return response.body;
} catch (e) {
return 'Error: $e';
}
}
Future<dynamic> fetchData(
String apiUrl,
String whitelabel, [
Map<String, dynamic>? queryParams,
]) async {
try {
final whiteLabel = whitelabels.getWhitelabel(whitelabel);
final client = await getHttpClient(whiteLabel.friendlyName);
var url = Uri.parse('${whiteLabel.apiUrl}/$apiUrl');
if (queryParams != null && queryParams.isNotEmpty) {
url = url.replace(queryParameters: queryParams);
}
final response = await client.get(
url,
headers: {'User-Agent': userAgent},
);
return response.body;
} catch (e) {
return {'statusCode': 500, 'body': e.toString()};
}
}
Stream<UserSelfV3Response> getUser(String whitelabel) async* {
try {
final user = await fetchData('v3/user/self', whitelabel);
if (user != null && user is String && user.isNotEmpty) {
yield UserSelfV3Response.fromJson(jsonDecode(user));
}
} catch (e) {
yield UserSelfV3Response();
}
}
Future<UserSelfV3Response> getUserNOS(String whitelabel) async {
try {
final user = await fetchData('v3/user/self', whitelabel);
return UserSelfV3Response.fromJson(jsonDecode(user));
} catch (e) {
return UserSelfV3Response();
}
}
Future<List<dynamic>> getNamedUser(String whitelabel, String username) async {
final user =
await fetchData('v3/user/named?username[0]=$username', whitelabel);
if (user != null && user.isNotEmpty) {
return jsonDecode(user);
}
return [];
}
Future<dynamic> getActivity(String whitelabel, String userId) async {
final activity = await fetchData('v3/user/activity?id=$userId', whitelabel);
if (activity != null && activity.isNotEmpty) {
return activity;
}
return [];
}
Future<dynamic> registerNotifications(String whitelabel, String token) async {
final response =
await postData('v3/push/web/register?token=$token', whitelabel);
LogService.logInfo('✅ Notifications registered! Response: $response');
return response;
}
Stream<List<CreatorModelV3>> getSubscribedCreators(String whitelabel) async* {
try {
final subres =
await fetchData('v3/user/subscriptions?active=true', whitelabel);
if (subres != null && subres is String && subres.isNotEmpty) {
List<dynamic> subscriptions = jsonDecode(subres);
List<String> creatorIds = subscriptions
.where((subscription) =>
subscription is Map<String, dynamic> &&
subscription['creator'] is String)
.map((subscription) => subscription['creator'] as String)
.toSet() // Remove duplicates
.toList();
List<CreatorModelV3> creators = [];
for (String id in creatorIds) {
try {
final creatorInfo =
await fetchData('v3/creator/info?id=$id', whitelabel);
if (creatorInfo != null &&
creatorInfo is String &&
creatorInfo.isNotEmpty) {
Map<String, dynamic> creatorJson = jsonDecode(creatorInfo);
creators.add(CreatorModelV3.fromJson(creatorJson));
}
} catch (e) {
continue;
}
}
yield creators;
}
} catch (e) {
yield [];
}
}
Stream<List<String>> getSubscribedCreatorsIds(String whitelabel) async* {
try {
final subres =
await fetchData('v3/user/subscriptions?active=true', whitelabel);
if (subres != null && subres.isNotEmpty) {
List<dynamic> subscriptions = jsonDecode(subres);
List<String> creatorIds = subscriptions
.where((subscription) =>
subscription is Map<String, dynamic> &&
subscription['creator'] is String)
.map((subscription) => subscription['creator'] as String)
.toList();
yield creatorIds;
}
} catch (e) {
yield [];
}
}
Future<ContentCreatorListV3Response> getMultiCreatorVideoFeed(
String whitelabel, List<String> creatorIds, int limit,
{List<ContentCreatorListLastItems>? lastElements}) async {
try {
if (creatorIds.isEmpty) {
return ContentCreatorListV3Response(blogPosts: [], lastElements: []);
}
final Map<String, dynamic> queryParams = {
for (int i = 0; i < creatorIds.length; i++) 'ids[$i]': creatorIds[i],
'limit': limit.toString(),
};
if (lastElements != null && lastElements.isNotEmpty) {
for (int i = 0; i < lastElements.length; i++) {
queryParams['fetchAfter[$i][creatorId]'] = lastElements[i].creatorId;
queryParams['fetchAfter[$i][blogPostId]'] =
lastElements[i].blogPostId;
queryParams['fetchAfter[$i][moreFetchable]'] =
lastElements[i].moreFetchable.toString();
}
}
final response =
await fetchData('v3/content/creator/list', whitelabel, queryParams);
if (response != null && response.isNotEmpty) {
return ContentCreatorListV3Response.fromJson(jsonDecode(response));
}
return ContentCreatorListV3Response(blogPosts: [], lastElements: []);
} catch (error) {
return ContentCreatorListV3Response(blogPosts: [], lastElements: []);
}
}
Future<List<BlogPostModelV3>> getChannelVideoFeed(
String whitelabel,
String creator,
int limit,
int fetchAfter, {
String? channel,
String? searchQuery,
Set<String>? contentTypes,
RangeValues? durationRange,
DateTime? fromDate,
DateTime? toDate,
bool? isAscending,
}) async {
try {
bool? hasVideo = contentTypes?.contains('Video');
bool? hasAudio = contentTypes?.contains('Audio');
bool? hasPicture = contentTypes?.contains('Picture');
bool? hasText = contentTypes?.contains('Text');
final Map<String, dynamic> queryParams = {
'id': creator,
'limit': limit.toString(),
'fetchAfter': fetchAfter.toString(),
if (channel != null) 'channel': channel,
if (fromDate != null) 'fromDate': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'toDate': toDate.toUtc().toIso8601String(),
if (contentTypes != null) ...{
'hasVideo': hasVideo.toString(),
'hasAudio': hasAudio.toString(),
'hasPicture': hasPicture.toString(),
'hasText': hasText.toString(),
},
if (durationRange != null) ...{
if ((durationRange.start * 60).round().toString() != '0')
'fromDuration': (durationRange.start * 60).round().toString(),
if ((durationRange.end * 60).round().toString() != '10800')
'toDuration': (durationRange.end * 60).round().toString(),
},
if (isAscending != null) 'sort': isAscending ? 'ASC' : 'DESC',
if (searchQuery != null && searchQuery.isNotEmpty)
'search': searchQuery,
};
final response =
await fetchData('v3/content/creator', whitelabel, queryParams);
if (response != null && response.isNotEmpty) {
List<dynamic> decodedResponse = jsonDecode(response);
return decodedResponse
.map((item) => BlogPostModelV3.fromJson(item))
.toList();
}
return [];
} catch (error) {
return [];
}
}
Future<List<GetProgressResponse>> getVideoProgress(
String whitelabel,
List<String> blogPostIds,
) async {
final Map<String, dynamic> requestBody = {
"ids": blogPostIds,
"contentType": "blogPost"
};
try {
final response =
await postData('v3/content/get/progress', whitelabel, requestBody);
if (response.isNotEmpty) {
final List<dynamic> jsonResponse = jsonDecode(response);
return jsonResponse
.map((data) => GetProgressResponse.fromJson(data))
.toList();
}
return [];
} catch (e) {
return [];
}
}
Stream<List<CreatorModelV3>> getCreators(String whitelabel,
{String? query}) async* {
try {
final apiUrl =
query != null ? 'v3/creator/list?search=$query' : 'v3/creator/list';
final subres = await fetchData(apiUrl, whitelabel);
if (subres != null && subres.isNotEmpty) {
List<dynamic> jsonList = jsonDecode(subres);
yield jsonList.map((json) => CreatorModelV3.fromJson(json)).toList();
}
} catch (e) {
yield [];
}
}
Stream<List<CreatorDiscoveryResponse>> getCreatorDiscovery(String whitelabel,
{String? query}) async* {
try {
final apiUrl = query != null
? 'v3/creator/discover?searchField=$query&featuredBlogPosts=1&creatorStats=true'
: 'v3/creator/discover?featuredBlogPosts=1&creatorStats=true';
final subres = await fetchData(apiUrl, whitelabel);
if (subres != null && subres.isNotEmpty) {
List<dynamic> jsonList = jsonDecode(subres)['creators'];
yield jsonList
.map((json) => CreatorDiscoveryResponse.fromJson(json))
.toList();
}
} catch (e) {
yield [];
}
}
Future<List<HistoryModelV3>> getHistory(String whitelabel,
{int? offset}) async {
try {
int offsetInt = offset ?? 0;
final response =
await fetchData('v3/content/history?offset=$offsetInt', whitelabel);
if (response != null && response.isNotEmpty) {
List<dynamic> jsonList = jsonDecode(response);
return jsonList.map((json) => HistoryModelV3.fromJson(json)).toList();
}
return [];
} catch (e) {
return [];
}
}
Stream<CreatorModelV3> getCreator(String whitelabel,
{String? urlname, int? id}) async* {
try {
final apiUrl = urlname != null
? 'v3/creator/named?creatorURL=$urlname'
: 'v3/creator/info?id=$id';
final creatorInfo = await fetchData(apiUrl, whitelabel);
if (creatorInfo != null && creatorInfo.isNotEmpty) {
List<dynamic> creatorList = jsonDecode(creatorInfo);
if (creatorList.isNotEmpty) {
Map<String, dynamic> creatorJson = creatorList.first;
final freshCreator = CreatorModelV3.fromJson(creatorJson);
yield freshCreator;
}
}
} catch (e) {
yield CreatorModelV3();
}
}
Future<StatsModel> getStats(String whitelabel, String creatorId) async {
try {
final stats =
await fetchData('v2/plan/info?creatorId=$creatorId', whitelabel);
if (stats != null && stats.isNotEmpty) {
dynamic statsJson = jsonDecode(stats);
return StatsModel(
totalIncome: statsJson['totalIncome'],
totalSubcriberCount: statsJson['totalSubscriberCount'],
);
}
return StatsModel(totalIncome: 0, totalSubcriberCount: 0);
} catch (e) {
return StatsModel(totalIncome: 0, totalSubcriberCount: 0);
}
}
Future<Map<String, dynamic>> getStatsV3(
String whitelabel, String creatorId) async {
try {
final stats =
await fetchData('v3/creator/stats?id=$creatorId', whitelabel);
if (stats != null && stats.isNotEmpty) {
dynamic statsJson = jsonDecode(stats);
return statsJson;
}
return {"error": true};
} catch (e) {
return {"error": true};
}
}
Future<Map<String, dynamic>> getUserInfo(String whitelabel) async {
try {
final userinfo = await fetchData('v3/user/self', whitelabel);
if (userinfo != null && userinfo.isNotEmpty) {
dynamic userinfoJson = jsonDecode(userinfo);
return userinfoJson;
}
return {"error": true};
} catch (e) {
return {"error": true};
}
}
Future<Map<String, dynamic>> getInvoices(String whitelabel) async {
try {
final invoices = await fetchData('v3/payment/invoice/list', whitelabel);
if (invoices != null && invoices.isNotEmpty) {
dynamic invoicesJson = jsonDecode(invoices);
return invoicesJson;
}
return {"error": true};
} catch (e) {
return {"error": true};
}
}
Stream<ContentPostV3Response> getBlogPost(
String whitelabel, String blogPostId) async* {
try {
final apiUrl = 'v3/content/post?id=$blogPostId';
final response = await fetchData(apiUrl, whitelabel);
if (response != null && response.isNotEmpty) {
try {
final jsonData = jsonDecode(response);
final parsed = ContentPostV3Response.fromJson(jsonData);
yield parsed;
} catch (e) {
yield ContentPostV3Response();
}
} else {
yield ContentPostV3Response();
}
} catch (e) {
yield ContentPostV3Response();
}
}
Future<String> likeBlogPost(String whitelabel, String blogPostId) async {
final response = await postData('v3/content/like', whitelabel,
{'contentType': 'blogPost', 'id': blogPostId});
final decodedres = jsonDecode(response);
if (decodedres.contains('like')) {
return 'success';
} else if (response.toString() == '[]') {
return 'removed';
} else {
return 'fail';
}
}
Future<String> dislikeBlogPost(String whitelabel, String blogPostId) async {
final response = await postData('v3/content/dislike', whitelabel,
{'contentType': 'blogPost', 'id': blogPostId});
final decodedres = jsonDecode(response);
if (decodedres.contains('dislike')) {
return 'success';
} else if (response.toString() == '[]') {
return 'removed';
} else {
return 'fail';
}
}
Future<String> likeComment(
String whitelabel, String commentId, String blogPostId) async {
final response = await postData('v3/comment/like', whitelabel,
{'comment': commentId, 'blogPost': blogPostId});
if (response.isEmpty || response.toString() == '[]' || response == 'null') {
return 'removed';
}
final decodedres = jsonDecode(response);
if (decodedres != null && decodedres.toString().contains('like')) {
return 'success';
}
return 'fail';
}
Future<String> dislikeComment(
String whitelabel, String commentId, String blogPostId) async {
final response = await postData('v3/comment/dislike', whitelabel,
{'comment': commentId, 'blogPost': blogPostId});
if (response.isEmpty || response.toString() == '[]' || response == 'null') {
return 'removed';
}
final decodedres = jsonDecode(response);
if (decodedres != null && decodedres.toString().contains('dislike')) {
return 'success';
}
return 'fail';
}
Future<List<BlogPostModelV3>> getRecommended(
String whitelabel, String blogPostId) async {
try {
final response =
await fetchData('v3/content/related?id=$blogPostId', whitelabel);
if (response != null && response.isNotEmpty) {
List<dynamic> decodedResponse = json.decode(response) as List<dynamic>;
return decodedResponse
.map((item) => BlogPostModelV3.fromJson(item))
.toList();
}
return [];
} catch (error) {
return [];
}
}
Future<List<CommentModel>> getComments(String whitelabel, String blogPostId,
int limit, String sortBy, String sortOrder,
{String? fetchAfter}) async {
try {
dynamic response;
if (fetchAfter != null) {
response = await fetchData(
'v3/comment?blogPost=$blogPostId&limit=$limit&fetchAfter=$fetchAfter&sortBy=$sortBy&sortDirection=$sortOrder',
whitelabel);
} else {
response = await fetchData(
'v3/comment?blogPost=$blogPostId&limit=$limit&sortBy=$sortBy&sortDirection=$sortOrder',
whitelabel);
}
if (response != null && response.isNotEmpty) {
final List<dynamic> decodedData =
json.decode(response) as List<dynamic>;
return decodedData
.map((item) => CommentModel.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (error) {
return [];
}
}
Future<List<CommentModel>> getReplies(String whitelabel, String comment,
String blogPost, int limit, String rid) async {
try {
final response = await fetchData(
'v3/comment/replies?comment=$comment&blogPost=$blogPost&limit=$limit&rid=$rid',
whitelabel);
if (response != null && response.isNotEmpty) {
final List<dynamic> decodedData =
json.decode(response) as List<dynamic>;
return decodedData
.map((item) => CommentModel.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (error) {
return [];
}
}
Future<CommentModel?> comment(
String whitelabel, String blogPostId, String comment,
{String? replyto}) async {
try {
final response = await postData('v3/comment', whitelabel, {
'blogPost': blogPostId,
if (replyto != null) 'replyTo': replyto,
'text': comment
});
if (response.isNotEmpty) {
final decodedData = json.decode(response) as Map<String, dynamic>;
return CommentModel.fromJson(decodedData);
}
return null;
} catch (error) {
return null;
}
}
Future<String> deleteComment(String whitelabel, String commentId) async {
final response = await postData('v3/comment/delete', whitelabel, {
'comment': commentId,
});
return response;
}
Future<String> editComment(
String whitelabel, String commentId, String text) async {
final response = await postData('v3/comment/edit', whitelabel, {
'comment': commentId,
'text': text,
});
return response;
}
Future<Map<String, dynamic>> getDelivery(
String whitelabel, String scenario, String entityId) async {
try {
final whiteLabel = whitelabels.getWhitelabel(whitelabel);
final client = await getHttpClient(whiteLabel.friendlyName);
final url = Uri.parse(
'${whiteLabel.apiUrl}/v3/delivery/info?scenario=$scenario&entityId=$entityId${scenario != 'download' ? '&outputKind=hls.mpegts' : ''}');
final response = await client.get(
url,
headers: {'User-Agent': userAgent},
);
_log.info(scenario != 'download' ? 'HLS Delivery URL:' : 'Download URL:');
_log.fine(response.body);
return {
'body': response.body,
'headers': response.headers,
'statusCode': response.statusCode,
};
} catch (e) {
return {
'body': '',
'headers': <String, String>{},
'statusCode': 500,
};
}
}
Future<String> getContent(String whitelabel, String type, String id) async {
try {
final res = await fetchData('v3/content/$type?id=$id', whitelabel);
return res;
} catch (e) {
return '';
}
}
Future<int> submitVote(String whitelabel, String id, int vote) async {
final response =
await postData('v3/poll/vote?pollId=$id&optionIndex=$vote', whitelabel);
if (response == 'OK') {
return 200;
} else {
return 500;
}
}
//because of the dumb way i handle progress (i have 3 different things that can call progress) we debounce this to avoid spam and stale data.
Timer? _progressDebounceTimer;
final Map<String, Map<String, dynamic>> _pendingProgress = {};
/// Store offline video progress locally
Future<void> _storeOfflineProgress(
String attachmentId, int progress, String contentType) async {
try {
final box = await Hive.openBox('offline_progress');
await box.put(attachmentId, {
'progress': progress,
'contentType': contentType,
'timestamp': DateTime.now().toIso8601String(),
});
_log.info('Stored offline progress for $attachmentId: $progress seconds');
} catch (e) {
_log.warning('Failed to store offline progress: $e');
}
}
/// Get stored offline progress for a specific attachment
Future<int> getOfflineProgress(String attachmentId) async {
try {
final box = await Hive.openBox('offline_progress');
final data = box.get(attachmentId) as Map?;
if (data != null && data['progress'] is int) {
_log.info(
'Retrieved offline progress for $attachmentId: ${data['progress']} seconds');
return data['progress'] as int;
}
} catch (e) {
_log.warning('Failed to get offline progress: $e');
}
return 0;
}
/// Sync all stored offline progress to Floatplane
Future<void> syncOfflineProgress(String whitelabel) async {
try {
final box = await Hive.openBox('offline_progress');
final keys = box.keys.toList();
if (keys.isEmpty) {
_log.info('No offline progress to sync');
return;
}
_log.info('Syncing ${keys.length} offline progress entries...');
int synced = 0;
for (final key in keys) {
try {
final data = box.get(key) as Map?;
if (data != null) {
await postData('v3/content/progress', whitelabel, {
'id': key,
'contentType': data['contentType'],
'progress': data['progress'],
});
await box.delete(key);
synced++;
}
} catch (e) {
_log.warning('Failed to sync progress for $key: $e');
}
}
_log.info('Successfully synced $synced offline progress entries');
} catch (e) {
_log.warning('Failed to sync offline progress: $e');
}
}
Future<void> progress(
String whitelabel, String id, int progress, String contentType,
{bool isOffline = false}) async {
if (id.isEmpty) return;
// Store offline progress locally instead of sending to API
if (isOffline) {
await _storeOfflineProgress(id, progress, contentType);
return;
}
_pendingProgress[id] = {
'progress': progress,
'contentType': contentType,
};
_progressDebounceTimer?.cancel();
_progressDebounceTimer = Timer(const Duration(seconds: 5), () async {
for (final entry in _pendingProgress.entries) {
final String entryId = entry.key;
final Map<String, dynamic> params = entry.value;
await postData('v3/content/progress', whitelabel, {
'id': entryId,
'contentType': params['contentType'],
'progress': params['progress'],
});
}
_pendingProgress.clear();
});
}
Future<void> iprogress(
String whitelabel, String id, int progress, String contentType,
{bool isOffline = false}) async {
if (id.isEmpty) return;
// Store offline progress locally instead of sending to API
if (isOffline) {
await _storeOfflineProgress(id, progress, contentType);
return;
}
await postData('v3/content/progress', whitelabel, {
'id': id,
'contentType': contentType,
'progress': progress,
});
}
Future<void> deleteHistory(String whitelabel) async {
await postData('v3/content/progress/clear', whitelabel);
}
Future<String> logout(String whitelabel) async {
final whiteLabel = whitelabels.getWhitelabel(whitelabel);
final client = await getHttpClient(whiteLabel.friendlyName);
final url = Uri.parse('${whiteLabel.apiUrl}/v3/auth/logout');
final response = await client.post(
url,
headers: {'User-Agent': userAgent},
);
return response.body;
}
Future<Map<String, dynamic>> subscribe(
String whitelabel, String creatorId) async {
final whiteLabel = whitelabels.getWhitelabel(whitelabel);
final client = await getHttpClient(whiteLabel.friendlyName);
final url =
Uri.parse('${whiteLabel.apiUrl}/v3/creator/subscribe?id=$creatorId');
final response = await client.post(
url,
headers: {'User-Agent': userAgent},
);
return jsonDecode(response.body);
}
Future<String> unsubscribe(String whitelabel, String creatorId) async {
final whiteLabel = whitelabels.getWhitelabel(whitelabel);
final client = await getHttpClient(whiteLabel.friendlyName);
final url =
Uri.parse('${whiteLabel.apiUrl}/v3/creator/unsubscribe?id=$creatorId');
final response = await client.post(
url,
headers: {'User-Agent': userAgent},
);
return response.body;
}
}