mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
607 lines
20 KiB
Dart
607 lines
20 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
|
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
|
import '../../../core/models/playlist_config.dart';
|
|
import '../../../core/models/iptv_models.dart';
|
|
import '../models/xtream_models.dart' as xm;
|
|
import 'dart:html' as html;
|
|
|
|
/// Xtream Codes API Service
|
|
///
|
|
/// Handles all communication with Xtream API servers
|
|
class XtreamService {
|
|
late final Dio _dio;
|
|
late final CacheOptions _cacheOptions;
|
|
|
|
PlaylistConfig? _currentPlaylist;
|
|
|
|
XtreamService() {
|
|
_dio = Dio(BaseOptions(
|
|
connectTimeout: const Duration(seconds: 15),
|
|
receiveTimeout: const Duration(seconds: 15),
|
|
),);
|
|
|
|
// Setup caching for API responses
|
|
_cacheOptions = CacheOptions(
|
|
store: HiveCacheStore('./cache'),
|
|
policy: CachePolicy.forceCache,
|
|
maxStale: const Duration(hours: 1),
|
|
priority: CachePriority.high,
|
|
);
|
|
|
|
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
|
|
}
|
|
|
|
/// Check if we're running on HTTPS (production)
|
|
bool get _isHttps {
|
|
return html.window.location.protocol == 'https:';
|
|
}
|
|
|
|
/// Wrap URL with proxy for all external IPTV URLs
|
|
///
|
|
/// Always use proxy for HTTP URLs to:
|
|
/// 1. Handle HTTPS->HTTP mixed content
|
|
/// 2. Provide Range header support for seeking
|
|
/// 3. Normalize headers and handle redirects
|
|
String _wrapWithProxy(String url) {
|
|
// Always proxy HTTP URLs for consistent streaming behavior
|
|
if (url.startsWith('http://')) {
|
|
final baseUrl = html.window.location.origin;
|
|
return '$baseUrl/api/xtream/$url';
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/// Initialize connection with a playlist
|
|
void setPlaylist(PlaylistConfig playlist) {
|
|
_currentPlaylist = playlist;
|
|
}
|
|
|
|
/// Generate stream URL for live TV
|
|
///
|
|
/// Returns the proxy URL for direct MPEG-TS streaming.
|
|
/// Player will use mpegts.js for playback (no server-side transcoding).
|
|
String getLiveStreamUrl(String streamId) {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
// Direct .ts URL for MPEG-TS streaming
|
|
final iptvUrl = '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.ts';
|
|
|
|
// Use proxy for direct streaming (no FFmpeg transcoding)
|
|
return _wrapWithProxy(iptvUrl);
|
|
}
|
|
|
|
/// Generate stream URL for VOD (movies)
|
|
///
|
|
/// [forceTranscoding] - If true, uses FFmpeg to transcode audio (guaranteed sound)
|
|
/// If false, uses direct URL (enables seeking but may lack audio on AC3/DTS)
|
|
String getVodStreamUrl(String streamId, String containerExtension, {bool forceTranscoding = false}) {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
final url = '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
|
|
|
|
if (forceTranscoding) {
|
|
// Use FFmpeg transcoding for guaranteed audio (no seeking)
|
|
final baseUrl = html.window.location.origin;
|
|
final encodedUrl = Uri.encodeComponent(url);
|
|
return '$baseUrl/api/stream/$streamId?url=$encodedUrl';
|
|
} else {
|
|
// Direct URL for seeking (audio only if AAC/MP3)
|
|
return _wrapWithProxy(url);
|
|
}
|
|
}
|
|
|
|
/// Generate stream URL for series episodes
|
|
///
|
|
/// [forceTranscoding] - If true, uses FFmpeg to transcode audio (guaranteed sound)
|
|
/// If false, uses direct URL (enables seeking but may lack audio on AC3/DTS)
|
|
String getSeriesStreamUrl(String streamId, String containerExtension, {bool forceTranscoding = false}) {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
final url = '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
|
|
|
|
if (forceTranscoding) {
|
|
// Use FFmpeg transcoding for guaranteed audio (no seeking)
|
|
final baseUrl = html.window.location.origin;
|
|
final encodedUrl = Uri.encodeComponent(url);
|
|
return '$baseUrl/api/stream/$streamId?url=$encodedUrl';
|
|
} else {
|
|
// Direct URL for seeking (audio only if AAC/MP3)
|
|
return _wrapWithProxy(url);
|
|
}
|
|
}
|
|
|
|
/// Authenticate and get server info
|
|
Future<Map<String, dynamic>> authenticate() async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
} catch (e) {
|
|
throw Exception('Authentication failed: $e');
|
|
}
|
|
}
|
|
|
|
/// Load categories mapping (category_id -> category_name)
|
|
Future<Map<String, String>> _getLiveCategories() async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_live_categories',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> categories = response.data as List<dynamic>;
|
|
final Map<String, String> categoryMap = {};
|
|
|
|
for (final cat in categories) {
|
|
final catData = cat as Map<String, dynamic>;
|
|
final id = catData['category_id']?.toString() ?? '';
|
|
final name = catData['category_name']?.toString() ?? 'Unknown';
|
|
if (id.isNotEmpty) {
|
|
categoryMap[id] = name;
|
|
}
|
|
}
|
|
|
|
return categoryMap;
|
|
} catch (e) {
|
|
return {}; // Return empty map on error, channels will be "Uncategorized"
|
|
}
|
|
}
|
|
|
|
/// Get all live TV channels grouped by category
|
|
///
|
|
/// Returns a Map where key is category name and value is list of channels
|
|
Future<Map<String, List<Channel>>> getLiveChannels() async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
// First, load categories to get the mapping
|
|
final categoryMap = await _getLiveCategories();
|
|
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_live_streams',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> streams = response.data as List<dynamic>;
|
|
final Map<String, List<Channel>> groupedChannels = {};
|
|
|
|
for (final streamData in streams) {
|
|
final data = streamData as Map<String, dynamic>;
|
|
// Get category name from our mapping using category_id
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
|
|
|
|
// Inject category_name into data before parsing
|
|
data['category_name'] = categoryName;
|
|
|
|
final channel = Channel.fromJson(data);
|
|
|
|
if (!groupedChannels.containsKey(categoryName)) {
|
|
groupedChannels[categoryName] = [];
|
|
}
|
|
groupedChannels[categoryName]!.add(channel);
|
|
}
|
|
|
|
return groupedChannels;
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch live channels: $e');
|
|
}
|
|
}
|
|
|
|
/// Load VOD categories mapping
|
|
Future<Map<String, String>> _getVodCategories() async {
|
|
if (_currentPlaylist == null) return {};
|
|
|
|
try {
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_vod_categories',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> categories = response.data as List<dynamic>;
|
|
final Map<String, String> categoryMap = {};
|
|
|
|
for (final cat in categories) {
|
|
final catData = cat as Map<String, dynamic>;
|
|
final id = catData['category_id']?.toString() ?? '';
|
|
final name = catData['category_name']?.toString() ?? 'Unknown';
|
|
if (id.isNotEmpty) categoryMap[id] = name;
|
|
}
|
|
|
|
return categoryMap;
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Get all VOD items (movies) grouped by category
|
|
Future<Map<String, List<VodItem>>> getVodItems() async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final categoryMap = await _getVodCategories();
|
|
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_vod_streams',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> vods = response.data as List<dynamic>;
|
|
final Map<String, List<VodItem>> groupedVods = {};
|
|
|
|
for (final vodData in vods) {
|
|
final data = vodData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
|
|
data['category_name'] = categoryName;
|
|
|
|
final vod = VodItem.fromJson(data);
|
|
|
|
if (!groupedVods.containsKey(categoryName)) {
|
|
groupedVods[categoryName] = [];
|
|
}
|
|
groupedVods[categoryName]!.add(vod);
|
|
}
|
|
|
|
return groupedVods;
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch VOD items: $e');
|
|
}
|
|
}
|
|
|
|
/// Load Series categories mapping
|
|
Future<Map<String, String>> _getSeriesCategories() async {
|
|
if (_currentPlaylist == null) return {};
|
|
|
|
try {
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_series_categories',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> categories = response.data as List<dynamic>;
|
|
final Map<String, String> categoryMap = {};
|
|
|
|
for (final cat in categories) {
|
|
final catData = cat as Map<String, dynamic>;
|
|
final id = catData['category_id']?.toString() ?? '';
|
|
final name = catData['category_name']?.toString() ?? 'Unknown';
|
|
if (id.isNotEmpty) categoryMap[id] = name;
|
|
}
|
|
|
|
return categoryMap;
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Get all series grouped by category
|
|
Future<Map<String, List<Series>>> getSeries() async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final categoryMap = await _getSeriesCategories();
|
|
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_series',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> seriesList = response.data as List<dynamic>;
|
|
final Map<String, List<Series>> groupedSeries = {};
|
|
|
|
for (final seriesData in seriesList) {
|
|
final data = seriesData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
|
|
data['category_name'] = categoryName;
|
|
|
|
final series = Series.fromJson(data);
|
|
|
|
if (!groupedSeries.containsKey(categoryName)) {
|
|
groupedSeries[categoryName] = [];
|
|
}
|
|
groupedSeries[categoryName]!.add(series);
|
|
}
|
|
|
|
return groupedSeries;
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch series: $e');
|
|
}
|
|
}
|
|
|
|
/// Get movies with pagination support
|
|
Future<List<xm.Movie>> getMoviesPaginated({int offset = 0, int limit = 100}) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
// Load categories for mapping
|
|
final categoryMap = await _getVodCategories();
|
|
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_vod_streams',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> allMovies = response.data as List<dynamic>;
|
|
|
|
// Apply pagination
|
|
final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit;
|
|
if (offset >= allMovies.length) return [];
|
|
|
|
final paginatedMovies = allMovies.sublist(offset, endIndex);
|
|
|
|
return paginatedMovies.map((movieData) {
|
|
final data = movieData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Movie.fromJson(data);
|
|
}).toList();
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch movies: $e');
|
|
}
|
|
}
|
|
|
|
/// Search movies in the entire catalogue
|
|
Future<List<xm.Movie>> searchMovies(String query) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
if (query.isEmpty) return [];
|
|
|
|
try {
|
|
final categoryMap = await _getVodCategories();
|
|
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_vod_streams',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> allMovies = response.data as List<dynamic>;
|
|
final queryLower = query.toLowerCase();
|
|
|
|
// Filter by search query
|
|
return allMovies
|
|
.where((m) {
|
|
final name = (m['name']?.toString() ?? '').toLowerCase();
|
|
return name.contains(queryLower);
|
|
})
|
|
.take(100) // Limit results
|
|
.map((movieData) {
|
|
final data = movieData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Movie.fromJson(data);
|
|
})
|
|
.toList();
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get series with pagination support (returns flat list)
|
|
Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
// Load categories for mapping
|
|
final categoryMap = await _getSeriesCategories();
|
|
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_series',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> allSeries = response.data as List<dynamic>;
|
|
|
|
// Apply pagination
|
|
final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit;
|
|
if (offset >= allSeries.length) return [];
|
|
|
|
final paginatedSeries = allSeries.sublist(offset, endIndex);
|
|
|
|
return paginatedSeries.map((seriesData) {
|
|
final data = seriesData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Series.fromJson(data);
|
|
}).toList();
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch series: $e');
|
|
}
|
|
}
|
|
|
|
/// Search series in the entire catalogue
|
|
Future<List<xm.Series>> searchSeries(String query) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
if (query.isEmpty) return [];
|
|
|
|
try {
|
|
final categoryMap = await _getSeriesCategories();
|
|
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_series',
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
final List<dynamic> allSeries = response.data as List<dynamic>;
|
|
final queryLower = query.toLowerCase();
|
|
|
|
// Filter by search query
|
|
return allSeries
|
|
.where((s) {
|
|
final name = (s['name']?.toString() ?? '').toLowerCase();
|
|
return name.contains(queryLower);
|
|
})
|
|
.take(100) // Limit results
|
|
.map((seriesData) {
|
|
final data = seriesData as Map<String, dynamic>;
|
|
final categoryId = data['category_id']?.toString() ?? '';
|
|
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
|
|
return xm.Series.fromJson(data);
|
|
})
|
|
.toList();
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get series info with seasons and episodes
|
|
Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_series_info',
|
|
'series_id': seriesId,
|
|
},
|
|
options: Options(extra: _cacheOptions.toExtra()),
|
|
);
|
|
|
|
return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>);
|
|
} catch (e) {
|
|
throw Exception('Failed to fetch series info: $e');
|
|
}
|
|
}
|
|
|
|
/// Get short EPG for a specific stream
|
|
///
|
|
/// Returns "Now" and "Next" program info
|
|
Future<List<EpgEntry>> getShortEpg(String streamId) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_short_epg',
|
|
'stream_id': streamId,
|
|
},
|
|
options: Options(
|
|
extra: CacheOptions(
|
|
store: _cacheOptions.store,
|
|
policy: CachePolicy.request,
|
|
maxStale: const Duration(minutes: 5), // EPG changes frequently
|
|
).toExtra(),
|
|
),
|
|
);
|
|
|
|
if (response.data == null || response.data['epg_listings'] == null) {
|
|
return [];
|
|
}
|
|
|
|
final List<dynamic> epgData = response.data['epg_listings'] as List<dynamic>;
|
|
return epgData
|
|
.map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>))
|
|
.toList();
|
|
} catch (e) {
|
|
// EPG is optional, don't throw on failure
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get short EPG as ShortEPG object (for EPGWidget)
|
|
Future<xm.ShortEPG> getShortEPG(String streamId) async {
|
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
|
|
|
try {
|
|
final response = await _dio.get(
|
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
|
queryParameters: {
|
|
'username': _currentPlaylist!.username,
|
|
'password': _currentPlaylist!.password,
|
|
'action': 'get_short_epg',
|
|
'stream_id': streamId,
|
|
},
|
|
options: Options(
|
|
extra: CacheOptions(
|
|
store: _cacheOptions.store,
|
|
policy: CachePolicy.request,
|
|
maxStale: const Duration(minutes: 5),
|
|
).toExtra(),
|
|
),
|
|
);
|
|
|
|
if (response.data == null) {
|
|
return const xm.ShortEPG();
|
|
}
|
|
|
|
return xm.ShortEPG.fromJson(response.data as Map<String, dynamic>);
|
|
} catch (e) {
|
|
// EPG is optional, don't throw on failure
|
|
return const xm.ShortEPG();
|
|
}
|
|
}
|
|
|
|
/// Dispose resources
|
|
void dispose() {
|
|
_dio.close();
|
|
}
|
|
}
|