fix(api): robust json parsing to prevent type cast crashes on empty/error responses

This commit is contained in:
Michael committed 2025-12-17 07:40:41 +01:00
1 parent 214443f6d5
commit 71d553f330
1 file changed
+55 -17
@@ -27,10 +27,12 @@ class XtreamServiceMobile {
XtreamServiceMobile(String cachePath) { XtreamServiceMobile(String cachePath) {
_cacheStore = HiveCacheStore(cachePath); _cacheStore = HiveCacheStore(cachePath);
_dio = Dio(BaseOptions( _dio = Dio(
connectTimeout: const Duration(seconds: 15), BaseOptions(
receiveTimeout: const Duration(seconds: 15), connectTimeout: const Duration(seconds: 15),
),); receiveTimeout: const Duration(seconds: 15),
),
);
// Setup caching for API responses - 24h cache for VOD content // Setup caching for API responses - 24h cache for VOD content
_cacheOptions = CacheOptions( _cacheOptions = CacheOptions(
@@ -71,7 +73,8 @@ class XtreamServiceMobile {
if (_resolvedIp != null) { if (_resolvedIp != null) {
print('XtreamServiceMobile: Will use IP $_resolvedIp for ${uri.host}'); print('XtreamServiceMobile: Will use IP $_resolvedIp for ${uri.host}');
} else { } else {
print('XtreamServiceMobile: DNS resolution failed, will use hostname directly'); print(
'XtreamServiceMobile: DNS resolution failed, will use hostname directly');
} }
} }
} }
@@ -179,6 +182,7 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
if (response.data is! List) return {};
final List<dynamic> categories = response.data as List<dynamic>; final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {}; final Map<String, String> categoryMap = {};
@@ -215,6 +219,7 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
if (response.data is! List) return {};
final List<dynamic> streams = response.data as List<dynamic>; final List<dynamic> streams = response.data as List<dynamic>;
final Map<String, List<Channel>> groupedChannels = {}; final Map<String, List<Channel>> groupedChannels = {};
@@ -256,6 +261,7 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
if (response.data is! List) return {};
final List<dynamic> categories = response.data as List<dynamic>; final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {}; final Map<String, String> categoryMap = {};
@@ -287,6 +293,7 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
if (response.data is! List) return {};
final List<dynamic> categories = response.data as List<dynamic>; final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {}; final Map<String, String> categoryMap = {};
@@ -304,7 +311,8 @@ class XtreamServiceMobile {
} }
/// Get movies with pagination support (uses in-memory cache for performance) /// Get movies with pagination support (uses in-memory cache for performance)
Future<List<xm.Movie>> getMoviesPaginated({int offset = 0, int limit = 100}) async { Future<List<xm.Movie>> getMoviesPaginated(
{int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
@@ -323,20 +331,27 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
_cachedMoviesRaw = response.data as List<dynamic>; if (response.data is! List) {
_cachedMoviesRaw = [];
} else {
_cachedMoviesRaw = response.data as List<dynamic>;
}
} }
final allMovies = _cachedMoviesRaw!; final allMovies = _cachedMoviesRaw!;
final categoryMap = _cachedVodCategories!; final categoryMap = _cachedVodCategories!;
// Apply pagination // Apply pagination
final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit; final endIndex = (offset + limit) > allMovies.length
? allMovies.length
: offset + limit;
if (offset >= allMovies.length) return []; if (offset >= allMovies.length) return [];
final paginatedMovies = allMovies.sublist(offset, endIndex); final paginatedMovies = allMovies.sublist(offset, endIndex);
return paginatedMovies.map((movieData) { return paginatedMovies.map((movieData) {
final data = Map<String, dynamic>.from(movieData as Map<String, dynamic>); final data =
Map<String, dynamic>.from(movieData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? ''; final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Movie.fromJson(data); return xm.Movie.fromJson(data);
@@ -364,6 +379,7 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
if (response.data is! List) return [];
final List<dynamic> allMovies = response.data as List<dynamic>; final List<dynamic> allMovies = response.data as List<dynamic>;
final queryLower = query.toLowerCase(); final queryLower = query.toLowerCase();
@@ -387,7 +403,8 @@ class XtreamServiceMobile {
} }
/// Get series with pagination support (uses in-memory cache for performance) /// Get series with pagination support (uses in-memory cache for performance)
Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async { Future<List<xm.Series>> getSeriesPaginated(
{int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
@@ -406,20 +423,27 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
_cachedSeriesRaw = response.data as List<dynamic>; if (response.data is! List) {
_cachedSeriesRaw = [];
} else {
_cachedSeriesRaw = response.data as List<dynamic>;
}
} }
final allSeries = _cachedSeriesRaw!; final allSeries = _cachedSeriesRaw!;
final categoryMap = _cachedSeriesCategories!; final categoryMap = _cachedSeriesCategories!;
// Apply pagination // Apply pagination
final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit; final endIndex = (offset + limit) > allSeries.length
? allSeries.length
: offset + limit;
if (offset >= allSeries.length) return []; if (offset >= allSeries.length) return [];
final paginatedSeries = allSeries.sublist(offset, endIndex); final paginatedSeries = allSeries.sublist(offset, endIndex);
return paginatedSeries.map((seriesData) { return paginatedSeries.map((seriesData) {
final data = Map<String, dynamic>.from(seriesData as Map<String, dynamic>); final data =
Map<String, dynamic>.from(seriesData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? ''; final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Series.fromJson(data); return xm.Series.fromJson(data);
@@ -447,6 +471,7 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
if (response.data is! List) return [];
final List<dynamic> allSeries = response.data as List<dynamic>; final List<dynamic> allSeries = response.data as List<dynamic>;
final queryLower = query.toLowerCase(); final queryLower = query.toLowerCase();
@@ -485,6 +510,9 @@ class XtreamServiceMobile {
options: _getOptions(), options: _getOptions(),
); );
if (response.data is! Map<String, dynamic>) {
throw Exception('Invalid response format for Series Info');
}
return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>); return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>);
} catch (e) { } catch (e) {
throw Exception('Failed to fetch series info: $e'); throw Exception('Failed to fetch series info: $e');
@@ -505,7 +533,9 @@ class XtreamServiceMobile {
'stream_id': streamId, 'stream_id': streamId,
}, },
options: Options( options: Options(
headers: _resolvedIp != null && _originalHost != null ? {'Host': _originalHost!} : null, headers: _resolvedIp != null && _originalHost != null
? {'Host': _originalHost!}
: null,
extra: CacheOptions( extra: CacheOptions(
store: _cacheOptions.store, store: _cacheOptions.store,
policy: CachePolicy.request, policy: CachePolicy.request,
@@ -518,7 +548,9 @@ class XtreamServiceMobile {
return []; return [];
} }
final List<dynamic> epgData = response.data['epg_listings'] as List<dynamic>; if (response.data['epg_listings'] is! List) return [];
final List<dynamic> epgData =
response.data['epg_listings'] as List<dynamic>;
return epgData return epgData
.map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>)) .map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -530,7 +562,8 @@ class XtreamServiceMobile {
/// Get short EPG as ShortEPG object (for EPGWidget) /// Get short EPG as ShortEPG object (for EPGWidget)
/// Caches results for 12 hours by default /// Caches results for 12 hours by default
Future<xm.ShortEPG> getShortEPG(String streamId, {bool forceRefresh = false}) async { Future<xm.ShortEPG> getShortEPG(String streamId,
{bool forceRefresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
@@ -543,7 +576,9 @@ class XtreamServiceMobile {
'stream_id': streamId, 'stream_id': streamId,
}, },
options: Options( options: Options(
headers: _resolvedIp != null && _originalHost != null ? {'Host': _originalHost!} : null, headers: _resolvedIp != null && _originalHost != null
? {'Host': _originalHost!}
: null,
extra: CacheOptions( extra: CacheOptions(
store: _cacheOptions.store, store: _cacheOptions.store,
policy: forceRefresh ? CachePolicy.refresh : CachePolicy.request, policy: forceRefresh ? CachePolicy.refresh : CachePolicy.request,
@@ -556,6 +591,9 @@ class XtreamServiceMobile {
return const xm.ShortEPG(); return const xm.ShortEPG();
} }
if (response.data is! Map<String, dynamic>) {
return const xm.ShortEPG();
}
return xm.ShortEPG.fromJson(response.data as Map<String, dynamic>); return xm.ShortEPG.fromJson(response.data as Map<String, dynamic>);
} catch (e) { } catch (e) {
// EPG is optional, don't throw on failure // EPG is optional, don't throw on failure