added cargo files
This commit is contained in:
532
PinePods-0.8.2/mobile/lib/services/pinepods/login_service.dart
Normal file
532
PinePods-0.8.2/mobile/lib/services/pinepods/login_service.dart
Normal file
@@ -0,0 +1,532 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class PinepodsLoginService {
|
||||
static const String userAgent = 'PinePods Mobile/1.0';
|
||||
|
||||
/// Verify if the server is a valid PinePods instance
|
||||
static Future<bool> verifyPinepodsInstance(String serverUrl) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/pinepods_check');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {'User-Agent': userAgent},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['pinepods_instance'] == true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial login - returns either API key or MFA session info
|
||||
static Future<InitialLoginResponse> initialLogin(String serverUrl, String username, String password) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final credentials = base64Encode(utf8.encode('$username:$password'));
|
||||
final authHeader = 'Basic $credentials';
|
||||
final url = Uri.parse('$normalizedUrl/api/data/get_key');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
// Check if MFA is required
|
||||
if (data['status'] == 'mfa_required' && data['mfa_required'] == true) {
|
||||
return InitialLoginResponse.mfaRequired(
|
||||
serverUrl: normalizedUrl,
|
||||
username: username,
|
||||
userId: data['user_id'],
|
||||
mfaSessionToken: data['mfa_session_token'],
|
||||
);
|
||||
}
|
||||
|
||||
// Normal flow - no MFA required
|
||||
final apiKey = data['retrieved_key'];
|
||||
if (apiKey != null) {
|
||||
return InitialLoginResponse.success(apiKey: apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
return InitialLoginResponse.failure('Authentication failed');
|
||||
} catch (e) {
|
||||
return InitialLoginResponse.failure('Error: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy method for backwards compatibility
|
||||
@deprecated
|
||||
static Future<String?> getApiKey(String serverUrl, String username, String password) async {
|
||||
final result = await initialLogin(serverUrl, username, password);
|
||||
return result.isSuccess ? result.apiKey : null;
|
||||
}
|
||||
|
||||
/// Verify API key is valid
|
||||
static Future<bool> verifyApiKey(String serverUrl, String apiKey) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/verify_key');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Api-Key': apiKey,
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['status'] == 'success';
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user ID
|
||||
static Future<int?> getUserId(String serverUrl, String apiKey) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/get_user');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Api-Key': apiKey,
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['status'] == 'success' && data['retrieved_id'] != null) {
|
||||
return data['retrieved_id'] as int;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user details
|
||||
static Future<UserDetails?> getUserDetails(String serverUrl, String apiKey, int userId) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/user_details_id/$userId');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Api-Key': apiKey,
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return UserDetails.fromJson(data);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get API configuration
|
||||
static Future<ApiConfig?> getApiConfig(String serverUrl, String apiKey) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/config');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Api-Key': apiKey,
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return ApiConfig.fromJson(data);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if MFA is enabled for user
|
||||
static Future<bool> checkMfaEnabled(String serverUrl, String apiKey, int userId) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/check_mfa_enabled/$userId');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Api-Key': apiKey,
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['mfa_enabled'] == true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify MFA code and get API key during login (secure flow)
|
||||
static Future<String?> verifyMfaAndGetKey(String serverUrl, String mfaSessionToken, String mfaCode) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa_and_get_key');
|
||||
|
||||
final requestBody = jsonEncode({
|
||||
'mfa_session_token': mfaSessionToken,
|
||||
'mfa_code': mfaCode,
|
||||
});
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
body: requestBody,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['verified'] == true && data['status'] == 'success') {
|
||||
return data['retrieved_key'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy MFA verification (for post-login MFA checks)
|
||||
@deprecated
|
||||
static Future<bool> verifyMfa(String serverUrl, String apiKey, int userId, String mfaCode) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa');
|
||||
|
||||
final requestBody = jsonEncode({
|
||||
'user_id': userId,
|
||||
'mfa_code': mfaCode,
|
||||
});
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Api-Key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
body: requestBody,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['verified'] == true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete login flow (new secure MFA implementation)
|
||||
static Future<LoginResult> login(String serverUrl, String username, String password) async {
|
||||
try {
|
||||
// Step 1: Verify server
|
||||
final isPinepods = await verifyPinepodsInstance(serverUrl);
|
||||
if (!isPinepods) {
|
||||
return LoginResult.failure('Not a valid PinePods server');
|
||||
}
|
||||
|
||||
// Step 2: Initial login - get API key or MFA session
|
||||
final initialResult = await initialLogin(serverUrl, username, password);
|
||||
|
||||
if (!initialResult.isSuccess) {
|
||||
return LoginResult.failure(initialResult.errorMessage ?? 'Login failed');
|
||||
}
|
||||
|
||||
if (initialResult.requiresMfa) {
|
||||
// MFA required - return MFA prompt state
|
||||
return LoginResult.mfaRequired(
|
||||
serverUrl: initialResult.serverUrl!,
|
||||
username: username,
|
||||
userId: initialResult.userId!,
|
||||
mfaSessionToken: initialResult.mfaSessionToken!,
|
||||
);
|
||||
}
|
||||
|
||||
// No MFA required - complete login with API key
|
||||
return await _completeLoginWithApiKey(
|
||||
serverUrl,
|
||||
username,
|
||||
initialResult.apiKey!,
|
||||
);
|
||||
} catch (e) {
|
||||
return LoginResult.failure('Error: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete MFA login flow
|
||||
static Future<LoginResult> completeMfaLogin({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String mfaSessionToken,
|
||||
required String mfaCode,
|
||||
}) async {
|
||||
try {
|
||||
// Verify MFA and get API key
|
||||
final apiKey = await verifyMfaAndGetKey(serverUrl, mfaSessionToken, mfaCode);
|
||||
if (apiKey == null) {
|
||||
return LoginResult.failure('Invalid MFA code');
|
||||
}
|
||||
|
||||
// Complete login with verified API key
|
||||
return await _completeLoginWithApiKey(serverUrl, username, apiKey);
|
||||
} catch (e) {
|
||||
return LoginResult.failure('Error: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete login flow with API key (common logic)
|
||||
static Future<LoginResult> _completeLoginWithApiKey(String serverUrl, String username, String apiKey) async {
|
||||
// Step 1: Verify API key
|
||||
final isValidKey = await verifyApiKey(serverUrl, apiKey);
|
||||
if (!isValidKey) {
|
||||
return LoginResult.failure('API key verification failed');
|
||||
}
|
||||
|
||||
// Step 2: Get user ID
|
||||
final userId = await getUserId(serverUrl, apiKey);
|
||||
if (userId == null) {
|
||||
return LoginResult.failure('Failed to get user ID');
|
||||
}
|
||||
|
||||
// Step 3: Get user details
|
||||
final userDetails = await getUserDetails(serverUrl, apiKey, userId);
|
||||
if (userDetails == null) {
|
||||
return LoginResult.failure('Failed to get user details');
|
||||
}
|
||||
|
||||
// Step 4: Get API configuration
|
||||
final apiConfig = await getApiConfig(serverUrl, apiKey);
|
||||
if (apiConfig == null) {
|
||||
return LoginResult.failure('Failed to get server configuration');
|
||||
}
|
||||
|
||||
return LoginResult.success(
|
||||
serverUrl: serverUrl,
|
||||
apiKey: apiKey,
|
||||
userId: userId,
|
||||
userDetails: userDetails,
|
||||
apiConfig: apiConfig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InitialLoginResponse {
|
||||
final bool isSuccess;
|
||||
final bool requiresMfa;
|
||||
final String? errorMessage;
|
||||
final String? apiKey;
|
||||
final String? serverUrl;
|
||||
final String? username;
|
||||
final int? userId;
|
||||
final String? mfaSessionToken;
|
||||
|
||||
InitialLoginResponse._({
|
||||
required this.isSuccess,
|
||||
required this.requiresMfa,
|
||||
this.errorMessage,
|
||||
this.apiKey,
|
||||
this.serverUrl,
|
||||
this.username,
|
||||
this.userId,
|
||||
this.mfaSessionToken,
|
||||
});
|
||||
|
||||
factory InitialLoginResponse.success({required String apiKey}) {
|
||||
return InitialLoginResponse._(
|
||||
isSuccess: true,
|
||||
requiresMfa: false,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
factory InitialLoginResponse.mfaRequired({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required int userId,
|
||||
required String mfaSessionToken,
|
||||
}) {
|
||||
return InitialLoginResponse._(
|
||||
isSuccess: true,
|
||||
requiresMfa: true,
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
userId: userId,
|
||||
mfaSessionToken: mfaSessionToken,
|
||||
);
|
||||
}
|
||||
|
||||
factory InitialLoginResponse.failure(String errorMessage) {
|
||||
return InitialLoginResponse._(
|
||||
isSuccess: false,
|
||||
requiresMfa: false,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginResult {
|
||||
final bool isSuccess;
|
||||
final bool requiresMfa;
|
||||
final String? errorMessage;
|
||||
final String? serverUrl;
|
||||
final String? apiKey;
|
||||
final String? username;
|
||||
final int? userId;
|
||||
final String? mfaSessionToken;
|
||||
final UserDetails? userDetails;
|
||||
final ApiConfig? apiConfig;
|
||||
|
||||
LoginResult._({
|
||||
required this.isSuccess,
|
||||
required this.requiresMfa,
|
||||
this.errorMessage,
|
||||
this.serverUrl,
|
||||
this.apiKey,
|
||||
this.username,
|
||||
this.userId,
|
||||
this.mfaSessionToken,
|
||||
this.userDetails,
|
||||
this.apiConfig,
|
||||
});
|
||||
|
||||
factory LoginResult.success({
|
||||
required String serverUrl,
|
||||
required String apiKey,
|
||||
required int userId,
|
||||
required UserDetails userDetails,
|
||||
required ApiConfig apiConfig,
|
||||
}) {
|
||||
return LoginResult._(
|
||||
isSuccess: true,
|
||||
requiresMfa: false,
|
||||
serverUrl: serverUrl,
|
||||
apiKey: apiKey,
|
||||
userId: userId,
|
||||
userDetails: userDetails,
|
||||
apiConfig: apiConfig,
|
||||
);
|
||||
}
|
||||
|
||||
factory LoginResult.failure(String errorMessage) {
|
||||
return LoginResult._(
|
||||
isSuccess: false,
|
||||
requiresMfa: false,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
factory LoginResult.mfaRequired({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required int userId,
|
||||
required String mfaSessionToken,
|
||||
}) {
|
||||
return LoginResult._(
|
||||
isSuccess: false,
|
||||
requiresMfa: true,
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
userId: userId,
|
||||
mfaSessionToken: mfaSessionToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserDetails {
|
||||
final int userId;
|
||||
final String? fullname;
|
||||
final String? username;
|
||||
final String? email;
|
||||
|
||||
UserDetails({
|
||||
required this.userId,
|
||||
this.fullname,
|
||||
this.username,
|
||||
this.email,
|
||||
});
|
||||
|
||||
factory UserDetails.fromJson(Map<String, dynamic> json) {
|
||||
return UserDetails(
|
||||
userId: json['UserID'],
|
||||
fullname: json['Fullname'],
|
||||
username: json['Username'],
|
||||
email: json['Email'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApiConfig {
|
||||
final String? apiUrl;
|
||||
final String? proxyUrl;
|
||||
final String? proxyHost;
|
||||
final String? proxyPort;
|
||||
final String? proxyProtocol;
|
||||
final String? reverseProxy;
|
||||
final String? peopleUrl;
|
||||
|
||||
ApiConfig({
|
||||
this.apiUrl,
|
||||
this.proxyUrl,
|
||||
this.proxyHost,
|
||||
this.proxyPort,
|
||||
this.proxyProtocol,
|
||||
this.reverseProxy,
|
||||
this.peopleUrl,
|
||||
});
|
||||
|
||||
factory ApiConfig.fromJson(Map<String, dynamic> json) {
|
||||
return ApiConfig(
|
||||
apiUrl: json['api_url'],
|
||||
proxyUrl: json['proxy_url'],
|
||||
proxyHost: json['proxy_host'],
|
||||
proxyPort: json['proxy_port'],
|
||||
proxyProtocol: json['proxy_protocol'],
|
||||
reverseProxy: json['reverse_proxy'],
|
||||
peopleUrl: json['people_url'],
|
||||
);
|
||||
}
|
||||
}
|
||||
405
PinePods-0.8.2/mobile/lib/services/pinepods/oidc_service.dart
Normal file
405
PinePods-0.8.2/mobile/lib/services/pinepods/oidc_service.dart
Normal file
@@ -0,0 +1,405 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class OidcService {
|
||||
static const String userAgent = 'PinePods Mobile/1.0';
|
||||
static const String callbackUrlScheme = 'pinepods';
|
||||
static const String callbackPath = '/auth/callback';
|
||||
|
||||
/// Get available OIDC providers from server
|
||||
static Future<List<OidcProvider>> getPublicProviders(String serverUrl) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/data/public_oidc_providers');
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {'User-Agent': userAgent},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final providers = (data['providers'] as List)
|
||||
.map((provider) => OidcProvider.fromJson(provider))
|
||||
.toList();
|
||||
return providers;
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate PKCE code verifier and challenge for secure OIDC flow
|
||||
static OidcPkce generatePkce() {
|
||||
final codeVerifier = _generateCodeVerifier();
|
||||
final codeChallenge = _generateCodeChallenge(codeVerifier);
|
||||
|
||||
return OidcPkce(
|
||||
codeVerifier: codeVerifier,
|
||||
codeChallenge: codeChallenge,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate random state parameter
|
||||
static String generateState() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// Store OIDC state on server (matches web implementation)
|
||||
static Future<bool> storeOidcState({
|
||||
required String serverUrl,
|
||||
required String state,
|
||||
required String clientId,
|
||||
String? originUrl,
|
||||
String? codeVerifier,
|
||||
}) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/auth/store_state');
|
||||
|
||||
final requestBody = jsonEncode({
|
||||
'state': state,
|
||||
'client_id': clientId,
|
||||
'origin_url': originUrl,
|
||||
'code_verifier': codeVerifier,
|
||||
});
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
body: requestBody,
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build authorization URL and return it for in-app browser use
|
||||
static Future<String?> buildOidcLoginUrl({
|
||||
required OidcProvider provider,
|
||||
required String serverUrl,
|
||||
required String state,
|
||||
OidcPkce? pkce,
|
||||
}) async {
|
||||
try {
|
||||
// Store state on server first - use web origin for in-app browser
|
||||
final stateStored = await storeOidcState(
|
||||
serverUrl: serverUrl,
|
||||
state: state,
|
||||
clientId: provider.clientId,
|
||||
originUrl: '$serverUrl/oauth/callback', // Use web callback for in-app browser
|
||||
codeVerifier: pkce?.codeVerifier, // Include PKCE code verifier
|
||||
);
|
||||
|
||||
if (!stateStored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build authorization URL
|
||||
final authUri = Uri.parse(provider.authorizationUrl);
|
||||
final queryParams = <String, String>{
|
||||
'client_id': provider.clientId,
|
||||
'response_type': 'code',
|
||||
'scope': provider.scope,
|
||||
'redirect_uri': '$serverUrl/api/auth/callback',
|
||||
'state': state,
|
||||
};
|
||||
|
||||
// Add PKCE parameters if provided
|
||||
if (pkce != null) {
|
||||
queryParams['code_challenge'] = pkce.codeChallenge;
|
||||
queryParams['code_challenge_method'] = 'S256';
|
||||
}
|
||||
|
||||
final authUrl = authUri.replace(queryParameters: queryParams);
|
||||
|
||||
return authUrl.toString();
|
||||
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract API key from callback URL (for in-app browser)
|
||||
static String? extractApiKeyFromUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
// Check if this is our callback URL with API key
|
||||
if (uri.path.contains('/oauth/callback')) {
|
||||
return uri.queryParameters['api_key'];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle OIDC callback and extract authentication result
|
||||
static OidcCallbackResult parseCallback(String callbackUrl) {
|
||||
try {
|
||||
final uri = Uri.parse(callbackUrl);
|
||||
final queryParams = uri.queryParameters;
|
||||
|
||||
// Check for error
|
||||
if (queryParams.containsKey('error')) {
|
||||
return OidcCallbackResult.error(
|
||||
error: queryParams['error'] ?? 'Unknown error',
|
||||
errorDescription: queryParams['error_description'],
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we have an API key directly (PinePods backend provides this)
|
||||
final apiKey = queryParams['api_key'];
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
return OidcCallbackResult.success(
|
||||
apiKey: apiKey,
|
||||
state: queryParams['state'],
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: Extract traditional OAuth code and state
|
||||
final code = queryParams['code'];
|
||||
final state = queryParams['state'];
|
||||
|
||||
if (code != null && state != null) {
|
||||
return OidcCallbackResult.success(
|
||||
code: code,
|
||||
state: state,
|
||||
);
|
||||
}
|
||||
|
||||
return OidcCallbackResult.error(
|
||||
error: 'missing_parameters',
|
||||
errorDescription: 'Neither API key nor authorization code found in callback',
|
||||
);
|
||||
} catch (e) {
|
||||
return OidcCallbackResult.error(
|
||||
error: 'parse_error',
|
||||
errorDescription: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete OIDC authentication by verifying with server
|
||||
static Future<OidcAuthResult> completeAuthentication({
|
||||
required String serverUrl,
|
||||
required String code,
|
||||
required String state,
|
||||
OidcPkce? pkce,
|
||||
}) async {
|
||||
try {
|
||||
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final url = Uri.parse('$normalizedUrl/api/auth/oidc_complete');
|
||||
|
||||
final requestBody = <String, dynamic>{
|
||||
'code': code,
|
||||
'state': state,
|
||||
};
|
||||
|
||||
// Add PKCE verifier if provided
|
||||
if (pkce != null) {
|
||||
requestBody['code_verifier'] = pkce.codeVerifier;
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
body: jsonEncode(requestBody),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return OidcAuthResult.success(
|
||||
apiKey: data['api_key'],
|
||||
userId: data['user_id'],
|
||||
serverUrl: normalizedUrl,
|
||||
);
|
||||
} else {
|
||||
final errorData = jsonDecode(response.body);
|
||||
return OidcAuthResult.failure(
|
||||
errorData['error'] ?? 'Authentication failed',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return OidcAuthResult.failure('Network error: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate secure random code verifier
|
||||
static String _generateCodeVerifier() {
|
||||
final random = Random.secure();
|
||||
// Generate 32 random bytes (256 bits) which will create a ~43 character base64url string
|
||||
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
|
||||
// Use base64url encoding (- and _ instead of + and /) and remove padding
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// Generate code challenge from verifier using SHA256
|
||||
static String _generateCodeChallenge(String codeVerifier) {
|
||||
final bytes = utf8.encode(codeVerifier);
|
||||
final digest = sha256.convert(bytes);
|
||||
return base64UrlEncode(digest.bytes)
|
||||
.replaceAll('=', '')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_');
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC Provider model
|
||||
class OidcProvider {
|
||||
final int providerId;
|
||||
final String providerName;
|
||||
final String clientId;
|
||||
final String authorizationUrl;
|
||||
final String scope;
|
||||
final String? buttonColor;
|
||||
final String? buttonText;
|
||||
final String? buttonTextColor;
|
||||
final String? iconSvg;
|
||||
|
||||
OidcProvider({
|
||||
required this.providerId,
|
||||
required this.providerName,
|
||||
required this.clientId,
|
||||
required this.authorizationUrl,
|
||||
required this.scope,
|
||||
this.buttonColor,
|
||||
this.buttonText,
|
||||
this.buttonTextColor,
|
||||
this.iconSvg,
|
||||
});
|
||||
|
||||
factory OidcProvider.fromJson(Map<String, dynamic> json) {
|
||||
return OidcProvider(
|
||||
providerId: json['provider_id'],
|
||||
providerName: json['provider_name'],
|
||||
clientId: json['client_id'],
|
||||
authorizationUrl: json['authorization_url'],
|
||||
scope: json['scope'],
|
||||
buttonColor: json['button_color'],
|
||||
buttonText: json['button_text'],
|
||||
buttonTextColor: json['button_text_color'],
|
||||
iconSvg: json['icon_svg'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Get display text for the provider button
|
||||
String get displayText => buttonText ?? 'Login with $providerName';
|
||||
|
||||
/// Get button color or default
|
||||
String get buttonColorHex => buttonColor ?? '#007bff';
|
||||
|
||||
/// Get button text color or default
|
||||
String get buttonTextColorHex => buttonTextColor ?? '#ffffff';
|
||||
}
|
||||
|
||||
/// PKCE (Proof Key for Code Exchange) parameters
|
||||
class OidcPkce {
|
||||
final String codeVerifier;
|
||||
final String codeChallenge;
|
||||
|
||||
OidcPkce({
|
||||
required this.codeVerifier,
|
||||
required this.codeChallenge,
|
||||
});
|
||||
}
|
||||
|
||||
/// OIDC callback parsing result
|
||||
class OidcCallbackResult {
|
||||
final bool isSuccess;
|
||||
final String? code;
|
||||
final String? state;
|
||||
final String? apiKey;
|
||||
final String? error;
|
||||
final String? errorDescription;
|
||||
|
||||
OidcCallbackResult._({
|
||||
required this.isSuccess,
|
||||
this.code,
|
||||
this.state,
|
||||
this.apiKey,
|
||||
this.error,
|
||||
this.errorDescription,
|
||||
});
|
||||
|
||||
factory OidcCallbackResult.success({
|
||||
String? code,
|
||||
String? state,
|
||||
String? apiKey,
|
||||
}) {
|
||||
return OidcCallbackResult._(
|
||||
isSuccess: true,
|
||||
code: code,
|
||||
state: state,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
factory OidcCallbackResult.error({
|
||||
required String error,
|
||||
String? errorDescription,
|
||||
}) {
|
||||
return OidcCallbackResult._(
|
||||
isSuccess: false,
|
||||
error: error,
|
||||
errorDescription: errorDescription,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasApiKey => apiKey != null && apiKey!.isNotEmpty;
|
||||
bool get hasCode => code != null && code!.isNotEmpty;
|
||||
}
|
||||
|
||||
/// OIDC authentication completion result
|
||||
class OidcAuthResult {
|
||||
final bool isSuccess;
|
||||
final String? apiKey;
|
||||
final int? userId;
|
||||
final String? serverUrl;
|
||||
final String? errorMessage;
|
||||
|
||||
OidcAuthResult._({
|
||||
required this.isSuccess,
|
||||
this.apiKey,
|
||||
this.userId,
|
||||
this.serverUrl,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
factory OidcAuthResult.success({
|
||||
required String apiKey,
|
||||
required int userId,
|
||||
required String serverUrl,
|
||||
}) {
|
||||
return OidcAuthResult._(
|
||||
isSuccess: true,
|
||||
apiKey: apiKey,
|
||||
userId: userId,
|
||||
serverUrl: serverUrl,
|
||||
);
|
||||
}
|
||||
|
||||
factory OidcAuthResult.failure(String errorMessage) {
|
||||
return OidcAuthResult._(
|
||||
isSuccess: false,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// lib/services/pinepods/pinepods_audio_service.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
|
||||
import 'package:pinepods_mobile/entities/chapter.dart';
|
||||
import 'package:pinepods_mobile/entities/person.dart';
|
||||
import 'package:pinepods_mobile/entities/transcript.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class PinepodsAudioService {
|
||||
final log = Logger('PinepodsAudioService');
|
||||
final AudioPlayerService _audioPlayerService;
|
||||
final PinepodsService _pinepodsService;
|
||||
final SettingsBloc _settingsBloc;
|
||||
|
||||
Timer? _episodeUpdateTimer;
|
||||
Timer? _userStatsTimer;
|
||||
int? _currentEpisodeId;
|
||||
int? _currentUserId;
|
||||
bool _isYoutube = false;
|
||||
double _lastRecordedPosition = 0;
|
||||
|
||||
/// Callbacks for pause/stop events
|
||||
Function()? _onPauseCallback;
|
||||
Function()? _onStopCallback;
|
||||
|
||||
PinepodsAudioService(
|
||||
this._audioPlayerService,
|
||||
this._pinepodsService,
|
||||
this._settingsBloc, {
|
||||
Function()? onPauseCallback,
|
||||
Function()? onStopCallback,
|
||||
}) : _onPauseCallback = onPauseCallback,
|
||||
_onStopCallback = onStopCallback;
|
||||
|
||||
/// Play a PinePods episode with full server integration
|
||||
Future<void> playPinepodsEpisode({
|
||||
required PinepodsEpisode pinepodsEpisode,
|
||||
bool resume = true,
|
||||
}) async {
|
||||
try {
|
||||
final settings = _settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) {
|
||||
log.warning('No user ID found - cannot play episode with server tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
_currentUserId = userId;
|
||||
_isYoutube = pinepodsEpisode.isYoutube;
|
||||
|
||||
log.info('Starting PinePods episode playback: ${pinepodsEpisode.episodeTitle}');
|
||||
|
||||
// Use the episode ID that's already available from the PinepodsEpisode
|
||||
final episodeId = pinepodsEpisode.episodeId;
|
||||
|
||||
if (episodeId == 0) {
|
||||
log.warning('Episode ID is 0 - cannot track playback');
|
||||
return;
|
||||
}
|
||||
|
||||
_currentEpisodeId = episodeId;
|
||||
|
||||
// Get podcast ID for settings
|
||||
final podcastId = await _pinepodsService.getPodcastIdFromEpisode(
|
||||
episodeId,
|
||||
userId,
|
||||
pinepodsEpisode.isYoutube,
|
||||
);
|
||||
|
||||
// Get playback settings (speed, skip times)
|
||||
final playDetails = await _pinepodsService.getPlayEpisodeDetails(
|
||||
userId,
|
||||
podcastId,
|
||||
pinepodsEpisode.isYoutube,
|
||||
);
|
||||
|
||||
// Fetch podcast 2.0 data including chapters
|
||||
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(episodeId, userId);
|
||||
|
||||
// Convert PinepodsEpisode to Episode for the audio player
|
||||
final episode = _convertToEpisode(pinepodsEpisode, playDetails, podcast2Data);
|
||||
|
||||
// Set playback speed
|
||||
await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed);
|
||||
|
||||
// Start playing with the existing audio service
|
||||
await _audioPlayerService.playEpisode(episode: episode, resume: resume);
|
||||
|
||||
// Handle skip intro if enabled and episode just started
|
||||
if (playDetails.startSkip > 0 && !resume) {
|
||||
await Future.delayed(const Duration(milliseconds: 500)); // Wait for player to initialize
|
||||
await _audioPlayerService.seek(position: playDetails.startSkip);
|
||||
}
|
||||
|
||||
// Add to history
|
||||
log.info('Adding episode $episodeId to history for user $userId');
|
||||
final initialPosition = resume ? (pinepodsEpisode.listenDuration ?? 0).toDouble() : 0.0;
|
||||
await _pinepodsService.recordListenDuration(
|
||||
episodeId,
|
||||
userId,
|
||||
initialPosition, // Send seconds like web app does
|
||||
pinepodsEpisode.isYoutube,
|
||||
);
|
||||
|
||||
// Queue episode for tracking
|
||||
log.info('Queueing episode $episodeId for user $userId');
|
||||
await _pinepodsService.queueEpisode(
|
||||
episodeId,
|
||||
userId,
|
||||
pinepodsEpisode.isYoutube,
|
||||
);
|
||||
|
||||
// Increment played count
|
||||
log.info('Incrementing played count for user $userId');
|
||||
await _pinepodsService.incrementPlayed(userId);
|
||||
|
||||
// Start periodic updates
|
||||
_startPeriodicUpdates();
|
||||
|
||||
log.info('PinePods episode playback started successfully');
|
||||
} catch (e) {
|
||||
log.severe('Error playing PinePods episode: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start periodic updates to server
|
||||
void _startPeriodicUpdates() {
|
||||
_stopPeriodicUpdates(); // Clean up any existing timers
|
||||
|
||||
log.info('Starting periodic updates - episode position every 15s, user stats every 60s');
|
||||
|
||||
// Episode position updates every 15 seconds (more frequent for reliability)
|
||||
_episodeUpdateTimer = Timer.periodic(
|
||||
const Duration(seconds: 15),
|
||||
(_) => _safeUpdateEpisodePosition(),
|
||||
);
|
||||
|
||||
// User listen time updates every 60 seconds
|
||||
_userStatsTimer = Timer.periodic(
|
||||
const Duration(seconds: 60),
|
||||
(_) => _safeUpdateUserListenTime(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Safely update episode position without affecting playback
|
||||
void _safeUpdateEpisodePosition() async {
|
||||
try {
|
||||
await _updateEpisodePosition();
|
||||
} catch (e) {
|
||||
log.warning('Periodic sync completely failed but playback continues: $e');
|
||||
// Completely isolate any network failures from affecting playback
|
||||
}
|
||||
}
|
||||
|
||||
/// Update episode position on server
|
||||
Future<void> _updateEpisodePosition() async {
|
||||
// Updating episode position
|
||||
if (_currentEpisodeId == null || _currentUserId == null) {
|
||||
log.warning('Skipping scheduled sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final positionState = _audioPlayerService.playPosition?.value;
|
||||
if (positionState == null) return;
|
||||
|
||||
final currentPosition = positionState.position.inSeconds.toDouble();
|
||||
|
||||
// Only update if position has changed by more than 2 seconds (more responsive)
|
||||
if ((currentPosition - _lastRecordedPosition).abs() > 2) {
|
||||
// Convert seconds to minutes for the API
|
||||
final currentPositionMinutes = currentPosition / 60.0;
|
||||
// Position changed, syncing to server
|
||||
|
||||
await _pinepodsService.recordListenDuration(
|
||||
_currentEpisodeId!,
|
||||
_currentUserId!,
|
||||
currentPosition, // Send seconds like web app does
|
||||
_isYoutube,
|
||||
);
|
||||
|
||||
_lastRecordedPosition = currentPosition;
|
||||
// Sync completed successfully
|
||||
}
|
||||
} catch (e) {
|
||||
log.warning('Failed to update episode position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely update user listen time without affecting playback
|
||||
void _safeUpdateUserListenTime() async {
|
||||
try {
|
||||
await _updateUserListenTime();
|
||||
} catch (e) {
|
||||
log.warning('User stats sync completely failed but playback continues: $e');
|
||||
// Completely isolate any network failures from affecting playback
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user listen time statistics
|
||||
Future<void> _updateUserListenTime() async {
|
||||
if (_currentUserId == null) return;
|
||||
|
||||
try {
|
||||
await _pinepodsService.incrementListenTime(_currentUserId!);
|
||||
// User listen time updated
|
||||
} catch (e) {
|
||||
log.warning('Failed to update user listen time: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync current position to server immediately (for pause/stop events)
|
||||
Future<void> syncCurrentPositionToServer() async {
|
||||
// Syncing current position to server
|
||||
|
||||
if (_currentEpisodeId == null || _currentUserId == null) {
|
||||
log.warning('Cannot sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final positionState = _audioPlayerService.playPosition?.value;
|
||||
if (positionState == null) {
|
||||
log.warning('Cannot sync - positionState is null');
|
||||
return;
|
||||
}
|
||||
|
||||
final currentPosition = positionState.position.inSeconds.toDouble();
|
||||
|
||||
log.info('Syncing position to server: ${currentPosition}s for episode $_currentEpisodeId');
|
||||
|
||||
await _pinepodsService.recordListenDuration(
|
||||
_currentEpisodeId!,
|
||||
_currentUserId!,
|
||||
currentPosition, // Send seconds like web app does
|
||||
_isYoutube,
|
||||
);
|
||||
|
||||
_lastRecordedPosition = currentPosition;
|
||||
log.info('Successfully synced position to server: ${currentPosition}s');
|
||||
} catch (e) {
|
||||
log.warning('Failed to sync position to server: $e');
|
||||
log.warning('Stack trace: ${StackTrace.current}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server position for current episode
|
||||
Future<double?> getServerPosition() async {
|
||||
if (_currentEpisodeId == null || _currentUserId == null) return null;
|
||||
|
||||
try {
|
||||
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
|
||||
_currentEpisodeId!,
|
||||
_currentUserId!,
|
||||
isYoutube: _isYoutube,
|
||||
);
|
||||
|
||||
return episodeMetadata?.listenDuration?.toDouble();
|
||||
} catch (e) {
|
||||
log.warning('Failed to get server position: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server position for any episode
|
||||
Future<double?> getServerPositionForEpisode(int episodeId, int userId, bool isYoutube) async {
|
||||
try {
|
||||
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
|
||||
episodeId,
|
||||
userId,
|
||||
isYoutube: isYoutube,
|
||||
);
|
||||
|
||||
return episodeMetadata?.listenDuration?.toDouble();
|
||||
} catch (e) {
|
||||
log.warning('Failed to get server position for episode $episodeId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record listen duration when episode ends or is stopped
|
||||
Future<void> recordListenDuration(double listenDuration) async {
|
||||
if (_currentEpisodeId == null || _currentUserId == null) return;
|
||||
|
||||
try {
|
||||
await _pinepodsService.recordListenDuration(
|
||||
_currentEpisodeId!,
|
||||
_currentUserId!,
|
||||
listenDuration,
|
||||
_isYoutube,
|
||||
);
|
||||
log.info('Recorded listen duration: ${listenDuration}s');
|
||||
} catch (e) {
|
||||
log.warning('Failed to record listen duration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle pause event - sync position to server
|
||||
Future<void> onPause() async {
|
||||
try {
|
||||
await syncCurrentPositionToServer();
|
||||
log.info('Pause event handled - position synced to server');
|
||||
} catch (e) {
|
||||
log.warning('Pause sync failed but pause succeeded: $e');
|
||||
}
|
||||
_onPauseCallback?.call();
|
||||
}
|
||||
|
||||
/// Handle stop event - sync position to server
|
||||
Future<void> onStop() async {
|
||||
try {
|
||||
await syncCurrentPositionToServer();
|
||||
log.info('Stop event handled - position synced to server');
|
||||
} catch (e) {
|
||||
log.warning('Stop sync failed but stop succeeded: $e');
|
||||
}
|
||||
_onStopCallback?.call();
|
||||
}
|
||||
|
||||
/// Stop periodic updates
|
||||
void _stopPeriodicUpdates() {
|
||||
_episodeUpdateTimer?.cancel();
|
||||
_userStatsTimer?.cancel();
|
||||
_episodeUpdateTimer = null;
|
||||
_userStatsTimer = null;
|
||||
}
|
||||
|
||||
/// Convert PinepodsEpisode to Episode for the audio player
|
||||
Episode _convertToEpisode(PinepodsEpisode pinepodsEpisode, PlayEpisodeDetails playDetails, Map<String, dynamic>? podcast2Data) {
|
||||
// Determine the content URL
|
||||
String contentUrl;
|
||||
if (pinepodsEpisode.downloaded && _currentEpisodeId != null && _currentUserId != null) {
|
||||
// Use stream URL for local episodes
|
||||
contentUrl = _pinepodsService.getStreamUrl(
|
||||
_currentEpisodeId!,
|
||||
_currentUserId!,
|
||||
isYoutube: pinepodsEpisode.isYoutube,
|
||||
isLocal: true,
|
||||
);
|
||||
} else if (pinepodsEpisode.isYoutube && _currentEpisodeId != null && _currentUserId != null) {
|
||||
// Use stream URL for YouTube episodes
|
||||
contentUrl = _pinepodsService.getStreamUrl(
|
||||
_currentEpisodeId!,
|
||||
_currentUserId!,
|
||||
isYoutube: true,
|
||||
isLocal: false,
|
||||
);
|
||||
} else {
|
||||
// Use original URL for external episodes
|
||||
contentUrl = pinepodsEpisode.episodeUrl;
|
||||
}
|
||||
|
||||
// Process podcast 2.0 data
|
||||
List<Chapter> chapters = [];
|
||||
List<Person> persons = [];
|
||||
List<TranscriptUrl> transcriptUrls = [];
|
||||
String? chaptersUrl;
|
||||
|
||||
if (podcast2Data != null) {
|
||||
// Extract chapters data
|
||||
final chaptersData = podcast2Data['chapters'] as List<dynamic>?;
|
||||
if (chaptersData != null) {
|
||||
try {
|
||||
chapters = chaptersData.map((chapterData) {
|
||||
return Chapter(
|
||||
title: chapterData['title'] ?? '',
|
||||
startTime: _parseDouble(chapterData['startTime'] ?? chapterData['start_time'] ?? 0) ?? 0.0,
|
||||
endTime: _parseDouble(chapterData['endTime'] ?? chapterData['end_time']),
|
||||
imageUrl: chapterData['img'] ?? chapterData['image'],
|
||||
url: chapterData['url'],
|
||||
toc: chapterData['toc'] ?? true,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
log.info('Loaded ${chapters.length} chapters from podcast 2.0 data');
|
||||
} catch (e) {
|
||||
log.warning('Error parsing chapters from podcast 2.0 data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Extract chapters URL if available
|
||||
chaptersUrl = podcast2Data['chapters_url'];
|
||||
|
||||
// Extract persons data
|
||||
final personsData = podcast2Data['people'] as List<dynamic>?;
|
||||
if (personsData != null) {
|
||||
try {
|
||||
persons = personsData.map((personData) {
|
||||
return Person(
|
||||
name: personData['name'] ?? '',
|
||||
role: personData['role'] ?? '',
|
||||
group: personData['group'] ?? '',
|
||||
image: personData['img'],
|
||||
link: personData['href'],
|
||||
);
|
||||
}).toList();
|
||||
|
||||
log.info('Loaded ${persons.length} persons from podcast 2.0 data');
|
||||
} catch (e) {
|
||||
log.warning('Error parsing persons from podcast 2.0 data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Extract transcript data
|
||||
final transcriptsData = podcast2Data['transcripts'] as List<dynamic>?;
|
||||
if (transcriptsData != null) {
|
||||
try {
|
||||
transcriptUrls = transcriptsData.map((transcriptData) {
|
||||
TranscriptFormat format = TranscriptFormat.unsupported;
|
||||
|
||||
// Determine format from URL, mime_type, or type field
|
||||
final url = transcriptData['url'] ?? '';
|
||||
final mimeType = transcriptData['mime_type'] ?? '';
|
||||
final type = transcriptData['type'] ?? '';
|
||||
|
||||
// Processing transcript
|
||||
|
||||
if (url.toLowerCase().contains('.json') ||
|
||||
mimeType.toLowerCase().contains('json') ||
|
||||
type.toLowerCase().contains('json')) {
|
||||
format = TranscriptFormat.json;
|
||||
// Detected JSON transcript
|
||||
} else if (url.toLowerCase().contains('.srt') ||
|
||||
mimeType.toLowerCase().contains('srt') ||
|
||||
type.toLowerCase().contains('srt') ||
|
||||
type.toLowerCase().contains('subrip') ||
|
||||
url.toLowerCase().contains('subrip')) {
|
||||
format = TranscriptFormat.subrip;
|
||||
// Detected SubRip transcript
|
||||
} else if (url.toLowerCase().contains('transcript') ||
|
||||
mimeType.toLowerCase().contains('html') ||
|
||||
type.toLowerCase().contains('html')) {
|
||||
format = TranscriptFormat.html;
|
||||
// Detected HTML transcript
|
||||
} else {
|
||||
log.warning('Transcript format not recognized: mimeType=$mimeType, type=$type');
|
||||
}
|
||||
|
||||
return TranscriptUrl(
|
||||
url: url,
|
||||
type: format,
|
||||
language: transcriptData['language'] ?? transcriptData['lang'] ?? 'en',
|
||||
rel: transcriptData['rel'],
|
||||
);
|
||||
}).toList();
|
||||
|
||||
log.info('Loaded ${transcriptUrls.length} transcript URLs from podcast 2.0 data');
|
||||
} catch (e) {
|
||||
log.warning('Error parsing transcripts from podcast 2.0 data: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Episode(
|
||||
guid: pinepodsEpisode.episodeUrl,
|
||||
podcast: pinepodsEpisode.podcastName,
|
||||
title: pinepodsEpisode.episodeTitle,
|
||||
description: pinepodsEpisode.episodeDescription,
|
||||
link: pinepodsEpisode.episodeUrl,
|
||||
publicationDate: DateTime.tryParse(pinepodsEpisode.episodePubDate) ?? DateTime.now(),
|
||||
author: '',
|
||||
duration: (pinepodsEpisode.episodeDuration * 1000).round(), // Convert to milliseconds
|
||||
contentUrl: contentUrl,
|
||||
position: pinepodsEpisode.completed ? 0 : ((pinepodsEpisode.listenDuration ?? 0) * 1000).round(), // Convert to milliseconds, reset to 0 for completed episodes
|
||||
imageUrl: pinepodsEpisode.episodeArtwork,
|
||||
played: pinepodsEpisode.completed,
|
||||
chapters: chapters,
|
||||
chaptersUrl: chaptersUrl,
|
||||
persons: persons,
|
||||
transcriptUrls: transcriptUrls,
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper method to safely parse double values
|
||||
double? _parseDouble(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is double) return value;
|
||||
if (value is int) return value.toDouble();
|
||||
if (value is String) {
|
||||
try {
|
||||
return double.parse(value);
|
||||
} catch (e) {
|
||||
log.warning('Failed to parse double from string: $value');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
void dispose() {
|
||||
_stopPeriodicUpdates();
|
||||
_currentEpisodeId = null;
|
||||
_currentUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
2170
PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_service.dart
Normal file
2170
PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user