[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,643 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'content_policy.dart';
|
||||
|
||||
List<dynamic> _array(dynamic raw) {
|
||||
if (raw == null) return [];
|
||||
if (raw is List) return raw;
|
||||
if (raw is Map) {
|
||||
for (final key in ['posters', 'data', 'result', 'items']) {
|
||||
if (raw[key] is List) return raw[key] as List;
|
||||
}
|
||||
}
|
||||
throw const ApiException(
|
||||
'The service returned an unsupported response shape.',
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _maps(dynamic raw) =>
|
||||
_array(raw)
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
Map<String, dynamic> _object(dynamic raw) {
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
throw const ApiException(
|
||||
'The service returned an unsupported response shape.',
|
||||
);
|
||||
}
|
||||
|
||||
List<TitleRecord> _titles(dynamic raw) =>
|
||||
_maps(raw)
|
||||
.where(ContentPolicy.allowsTitleJson)
|
||||
.map(TitleRecord.fromJson)
|
||||
.where((title) => title.id > 0 && title.title.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
/// Safe error suitable for presentation. Never holds request URLs or form data.
|
||||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
const ApiException(this.message, {this.statusCode});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// A single raw server page, compatible with existing List-based callers.
|
||||
/// Visible items may be empty even when the raw page has a continuation.
|
||||
class CatalogPage extends ListBase<TitleRecord> {
|
||||
CatalogPage(Iterable<TitleRecord> items, {required this.nextPage})
|
||||
: _items = List.unmodifiable(items);
|
||||
final List<TitleRecord> _items;
|
||||
final int? nextPage;
|
||||
@override
|
||||
int get length => _items.length;
|
||||
@override
|
||||
set length(int value) => throw UnsupportedError('Read-only page');
|
||||
@override
|
||||
TitleRecord operator [](int index) => _items[index];
|
||||
@override
|
||||
void operator []=(int index, TitleRecord value) =>
|
||||
throw UnsupportedError('Read-only page');
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
// Iran accepts a public route marker, not an upstream application secret.
|
||||
static const sharedKey = 'public';
|
||||
// Compatibility only; actor queries no longer send a key.
|
||||
static const actorKey = 'public';
|
||||
// Release AOT also uses --obfuscate. This removes an obvious literal from
|
||||
// binary string scans, not a secret: network destinations remain observable.
|
||||
static final String iranBaseUrl = String.fromCharCodes(
|
||||
[
|
||||
50,
|
||||
46,
|
||||
46,
|
||||
42,
|
||||
41,
|
||||
96,
|
||||
117,
|
||||
117,
|
||||
40,
|
||||
63,
|
||||
63,
|
||||
54,
|
||||
56,
|
||||
53,
|
||||
34,
|
||||
116,
|
||||
62,
|
||||
59,
|
||||
40,
|
||||
46,
|
||||
46,
|
||||
35,
|
||||
116,
|
||||
46,
|
||||
53,
|
||||
42,
|
||||
117,
|
||||
59,
|
||||
42,
|
||||
51,
|
||||
117,
|
||||
].map((value) => value ^ 0x5a),
|
||||
);
|
||||
static final String defaultBaseUrl = iranBaseUrl;
|
||||
static const _selectionKey = 'reelbox.api-selection.v1';
|
||||
// One production metadata origin; loopback remains an explicit test seam.
|
||||
static final _serviceOrigins = {Uri.parse(iranBaseUrl).origin};
|
||||
static const _legacyHosts = {
|
||||
'simbaapp.info',
|
||||
'winbedrives.com',
|
||||
'boxlimon.com',
|
||||
'gitcss.info',
|
||||
};
|
||||
final Dio _dio;
|
||||
// Only pending idempotent metadata reads are shared. Never retain detail,
|
||||
// source or catalog responses after completion: existing refreshes stay fresh.
|
||||
final _pendingMetadata = <String, Future<dynamic>>{};
|
||||
static const _maxPendingMetadata = 64;
|
||||
|
||||
static bool _shareableMetadata(Uri uri) {
|
||||
if (RegExp(
|
||||
r'^/api/(first/public/|(genre|country)/all/public/|movie/by/[0-9]+/public/|movie/by/actor/[0-9]+/public/|season/by/serie/[0-9]+/public/|role/by/poster/[0-9]+/public/|subtitles/by/(movie|episode)/[0-9]+/public/|(movie|serie|poster)/by/filtres/[^?]+/public/)$',
|
||||
).hasMatch(uri.path)) {
|
||||
// Unknown query actions can change the semantics of an otherwise known
|
||||
// route. Keep them outside the sharing allowlist.
|
||||
return !uri.queryParametersAll.containsKey('action');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final Future<SharedPreferences> Function() _preferences;
|
||||
bool _cacheRead = false;
|
||||
Future<void>? _cacheRestore;
|
||||
static const _cacheKey = 'reelbox.trusted-api.v1';
|
||||
String _baseUrl;
|
||||
int _selectionGeneration = 0;
|
||||
Future<void> _selectionWrite = Future<void>.value();
|
||||
ApiService({
|
||||
Dio? dio,
|
||||
String? baseUrl,
|
||||
Future<SharedPreferences> Function()? preferences,
|
||||
}) : _dio = dio ?? Dio(),
|
||||
_preferences = preferences ?? SharedPreferences.getInstance,
|
||||
_baseUrl = _validatedBase(baseUrl ?? defaultBaseUrl) {
|
||||
// Do not inherit upstream credentials or query defaults from injected Dio.
|
||||
_dio.options = BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 25),
|
||||
sendTimeout: const Duration(seconds: 12),
|
||||
followRedirects: false,
|
||||
maxRedirects: 0,
|
||||
responseType: ResponseType.plain,
|
||||
headers: {'Accept': 'application/json'},
|
||||
);
|
||||
}
|
||||
String get baseUrl => _baseUrl;
|
||||
String get hostRoot => Uri.parse(_baseUrl).origin;
|
||||
static bool _loopback(Uri uri) =>
|
||||
{'localhost', '127.0.0.1', '::1', '[::1]'}.contains(uri.host);
|
||||
static String _validatedBase(String value) {
|
||||
try {
|
||||
if (value != value.trim() ||
|
||||
!RegExp(r'^https?://[^/]+/api/?$').hasMatch(value)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final uri = Uri.parse(value);
|
||||
final trusted =
|
||||
uri.scheme == 'https' && _serviceOrigins.contains(uri.origin);
|
||||
final local =
|
||||
_loopback(uri) && (uri.scheme == 'http' || uri.scheme == 'https');
|
||||
if ((!trusted && !local) ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.hasQuery ||
|
||||
uri.hasFragment ||
|
||||
!{'/api', '/api/'}.contains(uri.path)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
return uri.replace(path: '/api/').toString();
|
||||
} on FormatException {
|
||||
throw const ApiException('Use a trusted HTTPS API server.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setBaseUrl(String url) async {
|
||||
final validated = _validatedBase(url);
|
||||
++_selectionGeneration;
|
||||
final write = _selectionWrite.then((_) async {
|
||||
final local = _loopback(Uri.parse(validated));
|
||||
if (!local) {
|
||||
final stored = await (await _preferences()).setString(
|
||||
_selectionKey,
|
||||
'primary',
|
||||
);
|
||||
if (!stored) {
|
||||
throw const ApiException('Could not save the API selection.');
|
||||
}
|
||||
}
|
||||
_cacheRead = true;
|
||||
_baseUrl = validated;
|
||||
});
|
||||
_selectionWrite = write.catchError((Object _) {});
|
||||
return write;
|
||||
}
|
||||
|
||||
// `discover` remains source-compatible; remote provider discovery is retired.
|
||||
Future<void> initialize({bool discover = true}) async {
|
||||
await _restoreCachedBase();
|
||||
}
|
||||
|
||||
Future<void> _restoreCachedBase() {
|
||||
if (_cacheRead || _loopback(Uri.parse(_baseUrl))) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
final running = _cacheRestore;
|
||||
if (running != null) return running;
|
||||
late final Future<void> work;
|
||||
work =
|
||||
(() async {
|
||||
final generation = _selectionGeneration;
|
||||
try {
|
||||
final prefs = await _preferences();
|
||||
if (_selectionGeneration != generation) return;
|
||||
// Stored provider choices and old discovery caches are migration
|
||||
// inputs, never permission to contact another production origin.
|
||||
await prefs.setString(_selectionKey, 'primary');
|
||||
if (prefs.containsKey(_cacheKey)) {
|
||||
await prefs.remove(_cacheKey);
|
||||
}
|
||||
} catch (_) {
|
||||
// Storage failure must not change the Iran-only in-memory default.
|
||||
}
|
||||
})().whenComplete(() {
|
||||
_cacheRead = true;
|
||||
if (identical(_cacheRestore, work)) _cacheRestore = null;
|
||||
});
|
||||
_cacheRestore = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
Uri _resolve(String path, {bool allowLegacy = true}) {
|
||||
try {
|
||||
if (path != path.trim() || path.startsWith('//')) {
|
||||
throw const FormatException();
|
||||
}
|
||||
var rawPath = path.split('?').first;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (RegExp(r'[\\\x00-\x20\x7f]').hasMatch(rawPath) ||
|
||||
RegExp(r'(^|/)\.{1,2}(/|$)').hasMatch(rawPath)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final decoded = Uri.decodeComponent(rawPath);
|
||||
if (decoded == rawPath) break;
|
||||
rawPath = decoded;
|
||||
}
|
||||
var uri = Uri.parse(path);
|
||||
if (uri.hasFragment || uri.userInfo.isNotEmpty) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final base = Uri.parse(_baseUrl);
|
||||
if (uri.hasScheme || uri.hasAuthority) {
|
||||
if (!{'http', 'https'}.contains(uri.scheme)) {
|
||||
throw const FormatException();
|
||||
}
|
||||
if (uri.origin == base.origin) {
|
||||
// Current-host requests never downgrade HTTPS.
|
||||
} else if (allowLegacy &&
|
||||
_legacyHosts.contains(uri.host) &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
((!uri.hasPort) || uri.port == 80 || uri.port == 443) &&
|
||||
(uri.path.startsWith('/api-user/newapi/') ||
|
||||
uri.path.startsWith('/api/'))) {
|
||||
uri = Uri.parse(
|
||||
'$hostRoot${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}',
|
||||
);
|
||||
} else {
|
||||
throw const FormatException();
|
||||
}
|
||||
} else {
|
||||
uri = base.resolve(path);
|
||||
}
|
||||
if (uri.origin != base.origin ||
|
||||
!(uri.path.startsWith('/api/') ||
|
||||
uri.path.startsWith('/api-user/'))) {
|
||||
throw const FormatException();
|
||||
}
|
||||
return uri;
|
||||
} on FormatException {
|
||||
throw const ApiException('The service link is not trusted.');
|
||||
}
|
||||
}
|
||||
|
||||
List<TitleRecord> decodeTitles(dynamic raw) => _titles(raw);
|
||||
Future<dynamic> _legacy(String file, Map<String, dynamic> query) async {
|
||||
await _restoreCachedBase();
|
||||
return _request(
|
||||
'GET',
|
||||
_resolve('$hostRoot/api-user/newapi/$file'),
|
||||
query: query,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> _titlePage(
|
||||
Future<dynamic> request,
|
||||
int page,
|
||||
) async {
|
||||
final raw = _array(await request);
|
||||
// Hidden announcements must not terminate upstream catalog pagination.
|
||||
return CatalogPage(_titles(raw), nextPage: raw.isEmpty ? null : page + 1);
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> search(
|
||||
String query, {
|
||||
int page = 1,
|
||||
bool english = false,
|
||||
}) => _titlePage(
|
||||
_legacy('like.php', {
|
||||
'action': english ? 'search-movie-en' : 'search-movie',
|
||||
'q': query,
|
||||
'pageno': page,
|
||||
}),
|
||||
page,
|
||||
);
|
||||
Future<List<TitleRecord>> recommended(int genre, {int page = 1}) async =>
|
||||
_titlePage(
|
||||
_legacy('filter.php', {
|
||||
'action': 'pishnahad',
|
||||
'garid': genre,
|
||||
'pageno': page,
|
||||
}),
|
||||
page,
|
||||
);
|
||||
Future<List<TitleRecord>> bestRandom({bool byYear = false, int page = 1}) =>
|
||||
_titlePage(
|
||||
_legacy('like.php', {
|
||||
'action': byYear ? 'best-rand-year' : 'best-rand-top',
|
||||
'pageno': page,
|
||||
}),
|
||||
page,
|
||||
);
|
||||
Future<List<String>> trending() async {
|
||||
final raw = _array(
|
||||
await _legacy('like.php', {'action': 'search-trand', 'pageno': 1}),
|
||||
);
|
||||
return raw
|
||||
.map(
|
||||
(item) => item is String
|
||||
? item
|
||||
: item is Map
|
||||
? item['title'] ?? item['q']
|
||||
: null,
|
||||
)
|
||||
.whereType<String>()
|
||||
.where(ContentPolicy.allowsText)
|
||||
.where((title) => title.trim().isNotEmpty)
|
||||
.take(14)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> actors({
|
||||
int page = 1,
|
||||
String search = '',
|
||||
}) async {
|
||||
await _restoreCachedBase();
|
||||
if (search.trim().isEmpty) {
|
||||
return _maps(
|
||||
ContentPolicy.sanitizeJson(
|
||||
await _legacy('like.php', {'action': 'all-actor', 'pageno': page}),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Native Retrofit evidence; the desktop like.php starsearch route currently
|
||||
// returns an empty non-JSON body rather than actor results.
|
||||
return _maps(
|
||||
await get(
|
||||
'$hostRoot/api-user/filmahang/star.php',
|
||||
query: {'action': 'starsearch', 'q': search.trim(), 'pageno': page},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> playlists({
|
||||
bool mine = false,
|
||||
String userId = '',
|
||||
int page = 1,
|
||||
}) async {
|
||||
// No-network compatibility seam for older callers. Public lists are removed.
|
||||
if (mine) {
|
||||
throw const ApiException('Server playlists are not part of Reelbox.');
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> playlistItems(
|
||||
int listId, {
|
||||
int page = 1,
|
||||
}) async => const [];
|
||||
Future<List<Map<String, dynamic>>> curatedLinks({bool best = false}) async {
|
||||
await _restoreCachedBase();
|
||||
final groups = _maps(
|
||||
await _legacy(best ? 'bartar.php' : 'pishnahad.php', {}),
|
||||
);
|
||||
final result = <Map<String, dynamic>>[];
|
||||
for (final group in groups) {
|
||||
final titles = group['title'], links = group['link'];
|
||||
if (titles is! List || links is! List) continue;
|
||||
for (var i = 0; i < titles.length && i < links.length; i++) {
|
||||
if (titles[i] is! String || links[i] is! String) continue;
|
||||
if (!ContentPolicy.allowsText(titles[i] as String) ||
|
||||
!ContentPolicy.allowsUrl(links[i] as String)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
result.add({
|
||||
'title': titles[i],
|
||||
'link': _resolve(links[i] as String).toString(),
|
||||
});
|
||||
} on ApiException {
|
||||
// Never expose a server-supplied arbitrary destination as API content.
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> curatedContent(String link, {int page = 1}) async {
|
||||
await _restoreCachedBase();
|
||||
final uri = _resolve(link);
|
||||
final params = {...uri.queryParameters, 'pageno': '$page'};
|
||||
return _titles(await get(uri.replace(queryParameters: params).toString()));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> home() async {
|
||||
final data = _object(await get('first/$sharedKey/'));
|
||||
// Unknown future server fields cannot silently introduce vendor feeds.
|
||||
const keys = {
|
||||
'posters',
|
||||
'genres',
|
||||
'slides',
|
||||
'actors',
|
||||
'countries',
|
||||
'movies',
|
||||
'series',
|
||||
'featured',
|
||||
'popular',
|
||||
'trending',
|
||||
};
|
||||
return {
|
||||
for (final entry in data.entries)
|
||||
if (keys.contains(entry.key)) entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<Tag>> _tags(String name) async =>
|
||||
_maps(await get('$name/all/$sharedKey/')).map(Tag.fromJson).toList();
|
||||
Future<List<Tag>> genres() => _tags('genre');
|
||||
Future<List<Tag>> countries() => _tags('country');
|
||||
Future<List<Tag>> categories() async => const [];
|
||||
Future<List<TitleRecord>> catalog({
|
||||
String kind = 'movie',
|
||||
int genre = 0,
|
||||
int country = 0,
|
||||
String order = 'created',
|
||||
int page = 0,
|
||||
}) async {
|
||||
if (!{'movie', 'serie', 'series', 'mixed', 'poster'}.contains(kind) ||
|
||||
page < 0) {
|
||||
throw const ApiException('Unsupported catalog filter.');
|
||||
}
|
||||
await _restoreCachedBase();
|
||||
final normalized = kind == 'series' ? 'serie' : kind;
|
||||
final mixed =
|
||||
normalized == 'mixed' || normalized == 'poster' || country != 0;
|
||||
final route = mixed
|
||||
? 'poster/by/filtres/$genre/$country/${Uri.encodeComponent(order)}/$page'
|
||||
: '$normalized/by/filtres/$genre/${Uri.encodeComponent(order)}/$page';
|
||||
final raw = _array(await _request('GET', _resolve('$route/$sharedKey/')));
|
||||
final titles = _titles(raw);
|
||||
return CatalogPage(
|
||||
country != 0 && {'movie', 'serie'}.contains(normalized)
|
||||
? titles.where((title) => title.type == normalized)
|
||||
: titles,
|
||||
nextPage: raw.isEmpty ? null : page + 1,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TitleRecord>> channels({
|
||||
int category = 0,
|
||||
int country = 0,
|
||||
int page = 0,
|
||||
}) async => const [];
|
||||
Future<TitleRecord> poster(int id) async =>
|
||||
TitleRecord.fromJson(_object(await get('movie/by/$id/$sharedKey/')));
|
||||
Future<TitleRecord> channel(int id) async =>
|
||||
throw const ApiException('This content is not part of Reelbox.');
|
||||
Future<List<SeasonRecord>> seasons(int id) async =>
|
||||
_maps(await get('season/by/serie/$id/$sharedKey/'))
|
||||
.map(SeasonRecord.fromJson)
|
||||
.toList();
|
||||
Future<List<TitleRecord>> actorTitles(int id) async =>
|
||||
_titles(await get('movie/by/actor/$id/$sharedKey/'));
|
||||
Future<List<Map<String, dynamic>>> roles(int posterId) async =>
|
||||
_maps(await get('role/by/poster/$posterId/$sharedKey/'));
|
||||
Future<List<Map<String, dynamic>>> subtitles(String kind, int id) async {
|
||||
if (!{'movie', 'episode'}.contains(kind)) {
|
||||
throw const ApiException('Unsupported subtitle kind.');
|
||||
}
|
||||
return _maps(await get('subtitles/by/$kind/$id/$sharedKey/'));
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> comments(int posterId) async => const [];
|
||||
|
||||
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async {
|
||||
await _restoreCachedBase();
|
||||
return ContentPolicy.sanitizeJson(
|
||||
await _request('GET', _resolve(path), query: query),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> post(String path, Map<String, dynamic> form) async {
|
||||
await _restoreCachedBase();
|
||||
return _request('POST', _resolve(path, allowLegacy: false), form: form);
|
||||
}
|
||||
|
||||
Future<dynamic> _request(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, dynamic>? query,
|
||||
Map<String, dynamic>? form,
|
||||
bool retryTransport = true,
|
||||
}) async {
|
||||
final policyUris = [
|
||||
uri,
|
||||
for (final action in [query?['action'], form?['action']])
|
||||
if (action != null) uri.replace(queryParameters: {'action': '$action'}),
|
||||
];
|
||||
if (policyUris.any((value) => !ContentPolicy.allowsApiPath(value)) ||
|
||||
!ContentPolicy.allowsUrl(uri.toString())) {
|
||||
throw const ApiException('This content is not part of Reelbox.');
|
||||
}
|
||||
// Key exactly the URI Dio will send, including origin, page and filters.
|
||||
// Validation above always runs, including for a potential cache hit.
|
||||
final requestUri = Options(method: method)
|
||||
.compose(_dio.options, uri.toString(), queryParameters: query)
|
||||
.uri;
|
||||
if (method != 'GET' || !_shareableMetadata(requestUri)) {
|
||||
return _sendRequest(method, requestUri, form: form,
|
||||
retryTransport: retryTransport);
|
||||
}
|
||||
final key = requestUri.toString();
|
||||
final pending = _pendingMetadata[key];
|
||||
if (pending != null) return pending;
|
||||
if (_pendingMetadata.length >= _maxPendingMetadata) {
|
||||
return _sendRequest(method, requestUri,
|
||||
retryTransport: retryTransport);
|
||||
}
|
||||
late final Future<dynamic> work;
|
||||
work = _sendRequest(method, requestUri,
|
||||
retryTransport: retryTransport).whenComplete(() {
|
||||
if (identical(_pendingMetadata[key], work)) {
|
||||
_pendingMetadata.remove(key);
|
||||
}
|
||||
});
|
||||
_pendingMetadata[key] = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
Future<dynamic> _sendRequest(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, dynamic>? form,
|
||||
required bool retryTransport,
|
||||
}) async {
|
||||
for (var attempt = 0; ; attempt++) {
|
||||
try {
|
||||
final response = await _dio.request<dynamic>(
|
||||
uri.toString(),
|
||||
data: form,
|
||||
options: Options(
|
||||
method: method,
|
||||
followRedirects: false,
|
||||
maxRedirects: 0,
|
||||
responseType: ResponseType.plain,
|
||||
contentType: form == null
|
||||
? null
|
||||
: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
final status = response.statusCode ?? 0;
|
||||
if (status < 200 || status >= 300) {
|
||||
throw ApiException(
|
||||
'The service rejected the request.',
|
||||
statusCode: status,
|
||||
);
|
||||
}
|
||||
if (response.data is String) {
|
||||
final text = (response.data as String)
|
||||
.replaceFirst('\uFEFF', '')
|
||||
.trim();
|
||||
return jsonDecode(text);
|
||||
}
|
||||
return response.data;
|
||||
} on DioException catch (error) {
|
||||
if (attempt == 0 &&
|
||||
retryTransport &&
|
||||
method == 'GET' &&
|
||||
error.response == null &&
|
||||
const {
|
||||
DioExceptionType.connectionError,
|
||||
DioExceptionType.connectionTimeout,
|
||||
DioExceptionType.sendTimeout,
|
||||
DioExceptionType.receiveTimeout,
|
||||
}.contains(error.type)) {
|
||||
// One replay of an idempotent read only. No discovery request storm,
|
||||
// alternate untrusted destination, denial retry or mutation replay.
|
||||
await _restoreCachedBase();
|
||||
continue;
|
||||
}
|
||||
final code = error.response?.statusCode;
|
||||
final message = switch (error.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'The service timed out. Please retry.',
|
||||
DioExceptionType.connectionError =>
|
||||
'Unable to connect. Check your connection and retry.',
|
||||
DioExceptionType.cancel => 'The request was cancelled.',
|
||||
_ when code == 401 || code == 403 =>
|
||||
'The service denied access to this content.',
|
||||
_ => 'The service request failed. Please retry.',
|
||||
};
|
||||
throw ApiException(message, statusCode: code);
|
||||
} on FormatException {
|
||||
throw const ApiException('The service returned an invalid response.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'content_policy.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// Iran read-only discovery contracts, independent of account APIs.
|
||||
class CatalogDiscovery {
|
||||
CatalogDiscovery(this.api);
|
||||
final ApiService api;
|
||||
|
||||
Future<dynamic> _legacy(String file, Map<String, dynamic> query) =>
|
||||
api.get('${api.hostRoot}/api-user/$file', query: query);
|
||||
|
||||
Future<CatalogPage> suggestions({int page = 1}) async =>
|
||||
_preservePage(await api.bestRandom(page: page), page);
|
||||
|
||||
Future<CatalogPage> search(
|
||||
String query, {
|
||||
int page = 1,
|
||||
bool english = false,
|
||||
DiscoverySearchFilter filter = const DiscoverySearchFilter(),
|
||||
}) async {
|
||||
final result = _preservePage(
|
||||
await api.search(query, page: page, english: english),
|
||||
page,
|
||||
);
|
||||
return CatalogPage(result.where(filter.accepts), nextPage: result.nextPage);
|
||||
}
|
||||
|
||||
CatalogPage _preservePage(List<TitleRecord> rows, int page) => CatalogPage(
|
||||
rows,
|
||||
nextPage: rows is CatalogPage
|
||||
? rows.nextPage
|
||||
: rows.isEmpty
|
||||
? null
|
||||
: page + 1,
|
||||
);
|
||||
|
||||
Future<DiscoveryObjectPage> directory(
|
||||
DiscoveryDirectory kind, {
|
||||
int page = 1,
|
||||
}) async {
|
||||
final themes = kind == DiscoveryDirectory.themes;
|
||||
final raw = discoveryRows(
|
||||
await _legacy(themes ? 'filmahang/star.php' : 'newapi/like.php', {
|
||||
'action': themes ? 'mazmon' : 'colection',
|
||||
'pageno': page,
|
||||
}),
|
||||
);
|
||||
return DiscoveryObjectPage(
|
||||
raw.map((row) => {...row, 'title': row['title'] ?? row['mazmon'] ?? ''}),
|
||||
nextPage: themes || raw.isEmpty ? null : page + 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// These endpoints return poster identifiers, not playable poster objects.
|
||||
Future<CatalogPage> sharedTitles({int page = 1, bool newest = false}) async {
|
||||
final rows = discoveryRows(
|
||||
await _legacy('filmahang/star.php', {
|
||||
'action': newest ? 'new' : 'share',
|
||||
'pageno': page,
|
||||
}),
|
||||
);
|
||||
final titles = <TitleRecord>[];
|
||||
final ids = <int>{};
|
||||
// Sequential hydration bounds server load on the account-free public API.
|
||||
for (final row in rows) {
|
||||
final id = int.tryParse('${row['id']}') ?? 0;
|
||||
if (id > 0 && ids.add(id)) titles.add(await api.poster(id));
|
||||
}
|
||||
return CatalogPage(titles, nextPage: rows.isEmpty ? null : page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
enum DiscoveryDirectory { themes, franchises }
|
||||
|
||||
class DiscoveryObjectPage extends ListBase<Map<String, dynamic>> {
|
||||
DiscoveryObjectPage(
|
||||
Iterable<Map<String, dynamic>> rows, {
|
||||
required this.nextPage,
|
||||
}) : _rows = List.unmodifiable(rows);
|
||||
final List<Map<String, dynamic>> _rows;
|
||||
final int? nextPage;
|
||||
@override
|
||||
int get length => _rows.length;
|
||||
@override
|
||||
set length(int value) => throw UnsupportedError('Read-only page');
|
||||
@override
|
||||
Map<String, dynamic> operator [](int index) => _rows[index];
|
||||
@override
|
||||
void operator []=(int index, Map<String, dynamic> value) =>
|
||||
throw UnsupportedError('Read-only page');
|
||||
}
|
||||
|
||||
/// The PHP search API has no evidenced advanced-filter parameters. Apply these
|
||||
/// explicitly to each returned page, retaining its unfiltered continuation.
|
||||
class DiscoverySearchFilter {
|
||||
const DiscoverySearchFilter({
|
||||
this.kind = 'mixed',
|
||||
this.minimumYear = 0,
|
||||
this.minimumRating = 0,
|
||||
this.genre = 0,
|
||||
this.country = 0,
|
||||
});
|
||||
final String kind;
|
||||
final int minimumYear, genre, country;
|
||||
final double minimumRating;
|
||||
bool accepts(TitleRecord title) =>
|
||||
(kind == 'mixed' ||
|
||||
(kind == 'serie' ? title.isSeries : title.type == kind)) &&
|
||||
(minimumYear == 0 || title.year >= minimumYear) &&
|
||||
title.imdb >= minimumRating &&
|
||||
(genre == 0 || title.genres.any((tag) => tag.id == genre)) &&
|
||||
(country == 0 || title.countries.any((tag) => tag.id == country));
|
||||
String get key => '$kind:$minimumYear:$minimumRating:$genre:$country';
|
||||
}
|
||||
|
||||
/// A complete actor endpoint is unpaged. Only an empty/unavailable endpoint
|
||||
/// switches to the observed actor-name search, whose raw pages remain paged.
|
||||
class ActorFilmography {
|
||||
ActorFilmography(this.discovery, {required this.id, required this.name});
|
||||
final CatalogDiscovery discovery;
|
||||
final int id;
|
||||
final String name;
|
||||
bool usingNameFallback = false;
|
||||
Future<CatalogPage> load(int page) async {
|
||||
if (page == 1 && id > 0 && !usingNameFallback) {
|
||||
try {
|
||||
final titles = await discovery.api.actorTitles(id);
|
||||
if (titles.isNotEmpty) return CatalogPage(titles, nextPage: null);
|
||||
} on ApiException catch (error) {
|
||||
if (error.statusCode == 401 || error.statusCode == 403) rethrow;
|
||||
}
|
||||
}
|
||||
usingNameFallback = true;
|
||||
if (name.trim().isEmpty) return CatalogPage([], nextPage: null);
|
||||
return discovery.search(name, page: page);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> actorMetadata(Map<String, dynamic> row) {
|
||||
final nested = row['actor'];
|
||||
final person = nested is Map ? Map<String, dynamic>.from(nested) : row;
|
||||
return {
|
||||
...person,
|
||||
'id': person['star_id'] ?? person['actor_id'] ?? person['id'] ?? 0,
|
||||
'name':
|
||||
person['star_name'] ??
|
||||
person['actor_name'] ??
|
||||
person['name'] ??
|
||||
person['title'] ??
|
||||
'',
|
||||
'image': person['image'] ?? person['img'] ?? person['photo'] ?? '',
|
||||
'role': row['role'] ?? person['role'] ?? '',
|
||||
'bio': person['bio'] ?? person['biography'] ?? person['description'] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
MediaSource? currentHistorySource(
|
||||
List<MediaSource> sources,
|
||||
String previousUrl,
|
||||
) {
|
||||
final playable = sources.where((source) {
|
||||
final uri = Uri.tryParse(source.url);
|
||||
return !source.isTrailer &&
|
||||
ContentPolicy.allowsUrl(source.url) &&
|
||||
uri != null &&
|
||||
{'https', 'http'}.contains(uri.scheme) &&
|
||||
uri.host.isNotEmpty;
|
||||
}).toList();
|
||||
return playable.where((source) => source.url == previousUrl).firstOrNull ??
|
||||
playable.firstOrNull;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> discoveryRows(dynamic raw) {
|
||||
if (raw is Map) {
|
||||
for (final key in ['posters', 'data', 'result', 'items']) {
|
||||
if (raw[key] is List) return discoveryRows(raw[key]);
|
||||
}
|
||||
}
|
||||
if (raw is! List) {
|
||||
throw const ApiException(
|
||||
'The service returned an unsupported response shape.',
|
||||
);
|
||||
}
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((row) => Map<String, dynamic>.from(row))
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/// A presentation boundary for the independent, film/series-only client.
|
||||
///
|
||||
/// Catalog/media hosts are not blocked merely because the original service owns
|
||||
/// them. The removed vendor destinations, announcements and navigation families
|
||||
/// are rejected separately. This also applies to imported/previously saved data.
|
||||
class ContentPolicy {
|
||||
static const _removedKeys = {
|
||||
'comment',
|
||||
'comments',
|
||||
'replies',
|
||||
'channel',
|
||||
'channels',
|
||||
'livechannel',
|
||||
'livechannels',
|
||||
'live_channels',
|
||||
'publicplaylist',
|
||||
'publicplaylists',
|
||||
'public_playlists',
|
||||
'playlists',
|
||||
'news',
|
||||
'news_url',
|
||||
'announcements',
|
||||
'announcement',
|
||||
'notifications',
|
||||
'notification',
|
||||
'update',
|
||||
'updates',
|
||||
'appupdate',
|
||||
'app_update',
|
||||
'app_version',
|
||||
'latest_version',
|
||||
'new_version',
|
||||
'version_code',
|
||||
'apk',
|
||||
'apk_url',
|
||||
'download_apk',
|
||||
'support',
|
||||
'support_url',
|
||||
'supportlink',
|
||||
'telegram',
|
||||
'telegram_url',
|
||||
'telegram_channel',
|
||||
'telegramaccount',
|
||||
'ads',
|
||||
'advertisements',
|
||||
'advertisement',
|
||||
'admob',
|
||||
'shop',
|
||||
'shop_url',
|
||||
'store_url',
|
||||
'melodybox',
|
||||
'norton',
|
||||
};
|
||||
static const _removedTypes = {
|
||||
'channel',
|
||||
'live',
|
||||
'live_channel',
|
||||
'news',
|
||||
'announcement',
|
||||
'advertisement',
|
||||
'ad',
|
||||
'app',
|
||||
'apk',
|
||||
'update',
|
||||
'support',
|
||||
'public_playlist',
|
||||
};
|
||||
static final _formatting = RegExp(
|
||||
r'[\u064b-\u065f\u0670\u06d6-\u06ed\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]',
|
||||
);
|
||||
static final _urls = RegExp(
|
||||
r'''(?:https?://|tg://|telegram://|www\.)[^\s<>"')]+''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final _handles = RegExp(r'(^|[\s>(])@[a-zA-Z][a-zA-Z0-9_]{3,}');
|
||||
|
||||
static String _decode(String value) {
|
||||
var result = value;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final decoded = result.replaceAllMapped(RegExp(r'(?:%[0-9a-fA-F]{2})+'), (
|
||||
match,
|
||||
) {
|
||||
try {
|
||||
return Uri.decodeComponent(match.group(0)!);
|
||||
} on FormatException {
|
||||
return match.group(0)!;
|
||||
} on ArgumentError {
|
||||
return match.group(0)!;
|
||||
}
|
||||
});
|
||||
if (decoded == result) break;
|
||||
result = decoded;
|
||||
}
|
||||
return result
|
||||
.replaceAllMapped(
|
||||
RegExp(r'&#(x[0-9a-f]+|[0-9]+);', caseSensitive: false),
|
||||
(match) {
|
||||
final entity = match.group(1)!;
|
||||
final code = int.tryParse(
|
||||
entity.startsWith(RegExp('[xX]')) ? entity.substring(1) : entity,
|
||||
radix: entity.startsWith(RegExp('[xX]')) ? 16 : 10,
|
||||
);
|
||||
return code != null && code >= 0 && code <= 0x10ffff
|
||||
? String.fromCharCode(code)
|
||||
: '';
|
||||
},
|
||||
)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll(' ', ' ');
|
||||
}
|
||||
|
||||
static String _normal(String value) =>
|
||||
_decode(value)
|
||||
.toLowerCase()
|
||||
.replaceAll('ي', 'ی')
|
||||
.replaceAll('ى', 'ی')
|
||||
.replaceAll('ك', 'ک')
|
||||
.replaceAll(_formatting, '');
|
||||
static String _compact(String value) =>
|
||||
_normal(value).replaceAll(RegExp(r'[\s_\-\u0640]+'), '');
|
||||
static bool _brand(String value) {
|
||||
final compact = _compact(value);
|
||||
return compact.contains('ملودیباکس') ||
|
||||
compact.contains('ملودیبکس') ||
|
||||
compact.contains('melodybox') ||
|
||||
compact.contains('melodibox') ||
|
||||
compact.contains('نورتون') ||
|
||||
compact.contains('نورتن') ||
|
||||
compact.contains('norton');
|
||||
}
|
||||
|
||||
static bool allowsText(String value) {
|
||||
final normal = _normal(value);
|
||||
final compact = _compact(value);
|
||||
if (_brand(value) ||
|
||||
normal.contains('تلگرام') ||
|
||||
RegExp(r'\btelegram\b').hasMatch(normal) ||
|
||||
_handles.hasMatch(normal)) {
|
||||
return false;
|
||||
}
|
||||
if (RegExp(
|
||||
r'(دانلود|نصب|دریافت).{0,30}نسخه.{0,18}(برنامه|اپلیکیشن|سیمبا|مووی)',
|
||||
dotAll: true,
|
||||
).hasMatch(normal) ||
|
||||
RegExp(
|
||||
r'(آپدیت|بروزرسانی|بهروز رسانی|بهروزرسانی).{0,20}(برنامه|اپلیکیشن|سیمبا)',
|
||||
dotAll: true,
|
||||
).hasMatch(normal) ||
|
||||
RegExp(
|
||||
r'(install|download|update).{0,24}(latest|new).{0,12}(app|version)',
|
||||
dotAll: true,
|
||||
).hasMatch(normal)) {
|
||||
return false;
|
||||
}
|
||||
if ((compact.contains('originalapp') ||
|
||||
compact.contains('moviebox') ||
|
||||
compact.contains('سیمبا')) &&
|
||||
(compact.contains('support') ||
|
||||
compact.contains('news') ||
|
||||
compact.contains('پشتیبانی'))) {
|
||||
return false;
|
||||
}
|
||||
return _urls
|
||||
.allMatches(_decode(value))
|
||||
.every((match) => allowsUrl(match.group(0)!));
|
||||
}
|
||||
|
||||
static String sanitizeText(String value) =>
|
||||
allowsText(value) ? value.replaceAll(_formatting, '').trim() : '';
|
||||
|
||||
static bool allowsUrl(String value) {
|
||||
if (value.isEmpty) return true;
|
||||
if (value != value.trim() || RegExp(r'[\x00-\x20\x7f]').hasMatch(value)) {
|
||||
return false;
|
||||
}
|
||||
final decoded = _decode(value);
|
||||
if (_brand(decoded) || decoded.contains('\\')) return false;
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
decoded.startsWith('www.') ? 'https://$decoded' : decoded,
|
||||
);
|
||||
if (uri.userInfo.isNotEmpty ||
|
||||
(uri.hasScheme &&
|
||||
!{'http', 'https'}.contains(uri.scheme.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
final host = uri.host.toLowerCase();
|
||||
if ([
|
||||
't.me',
|
||||
'telegram.me',
|
||||
'telegram.dog',
|
||||
'telegram.org',
|
||||
'telegra.ph',
|
||||
].any((blocked) => host == blocked || host.endsWith('.$blocked'))) {
|
||||
return false;
|
||||
}
|
||||
final path = _normal(uri.path);
|
||||
if (RegExp(r'\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)(/|$)')
|
||||
.hasMatch(path)) {
|
||||
return false;
|
||||
}
|
||||
if (RegExp(
|
||||
r'(^|/)(original[-_]?app|moviebox|simba)/(support|news|updates?|downloads?)(/|$)',
|
||||
).hasMatch(path)) {
|
||||
return false;
|
||||
}
|
||||
if (RegExp(
|
||||
r'(^|/)(app[-_]?updates?|download[-_]?apk|latest[-_]?version)(/|$)',
|
||||
).hasMatch(path)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool allowsApiPath(Uri uri) {
|
||||
final path = _normal(uri.path);
|
||||
final actions = (uri.queryParametersAll['action'] ?? const <String>[]).map(
|
||||
_normal,
|
||||
);
|
||||
if (RegExp(
|
||||
r'(^|/)(comments?|replies|reply|channels?|public[-_]?playlists?|news|updates?|support)([-_][a-z0-9_-]+)?(\.php|/|$)',
|
||||
).hasMatch(path)) {
|
||||
return false;
|
||||
}
|
||||
return actions.every(
|
||||
(action) =>
|
||||
!{
|
||||
'comment',
|
||||
'comments',
|
||||
'add-comment',
|
||||
'reply',
|
||||
'replies',
|
||||
'pasokh',
|
||||
'showlist',
|
||||
'show-list_fav',
|
||||
'public-playlists',
|
||||
'playlist-items',
|
||||
'app-update',
|
||||
'app-news',
|
||||
'telegram',
|
||||
'support',
|
||||
}.contains(action) &&
|
||||
!RegExp(r'(^|[-_])(comments?|replies|reply|pasokh)([-_]|$)')
|
||||
.hasMatch(action),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _removedRecord(Map<String, dynamic> value) {
|
||||
final type = _normal('${value['type'] ?? value['kind'] ?? ''}');
|
||||
if (_removedTypes.contains(type)) return true;
|
||||
for (final key in [
|
||||
'title',
|
||||
'name',
|
||||
'description',
|
||||
'note',
|
||||
'message',
|
||||
'text',
|
||||
'content',
|
||||
'body',
|
||||
'summary',
|
||||
'label',
|
||||
]) {
|
||||
final text = value[key];
|
||||
if (text is String && !allowsText(text)) return true;
|
||||
}
|
||||
for (final key in [
|
||||
'link',
|
||||
'url',
|
||||
'website',
|
||||
'button_url',
|
||||
'download_url',
|
||||
'apk_url',
|
||||
]) {
|
||||
final url = value[key];
|
||||
if (url is String && !allowsUrl(url)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool allowsTitleJson(Map<String, dynamic> json) =>
|
||||
!_removedRecord(json);
|
||||
|
||||
/// Returns a fresh JSON tree; removed records/fields never become blank cards.
|
||||
static dynamic sanitizeJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final map = Map<String, dynamic>.from(value);
|
||||
if (_removedRecord(map)) return null;
|
||||
final result = <String, dynamic>{};
|
||||
for (final entry in map.entries) {
|
||||
if (_removedKeys.contains(_normal(entry.key))) continue;
|
||||
final item = sanitizeJson(entry.value);
|
||||
if (item != null) result[entry.key] = item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(sanitizeJson).where((v) => v != null).toList();
|
||||
}
|
||||
if (value is String) return allowsText(value) ? value : null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'api_service.dart';
|
||||
import 'content_policy.dart';
|
||||
|
||||
class HomeBanner {
|
||||
const HomeBanner._({
|
||||
required this.titleEn,
|
||||
required this.titleFa,
|
||||
required this.subtitleEn,
|
||||
required this.subtitleFa,
|
||||
required this.target,
|
||||
required this.isAdvertisement,
|
||||
this.image,
|
||||
});
|
||||
|
||||
final String titleEn, titleFa, subtitleEn, subtitleFa;
|
||||
final Uri target;
|
||||
final Uri? image;
|
||||
final bool isAdvertisement;
|
||||
String title(bool persian) => persian ? titleFa : titleEn;
|
||||
String subtitle(bool persian) => persian ? subtitleFa : subtitleEn;
|
||||
|
||||
static final telegram = HomeBanner._(
|
||||
titleEn: 'ReelBox updates',
|
||||
titleFa: 'تازههای ریلباکس',
|
||||
subtitleEn: 'Updates and announcements',
|
||||
subtitleFa: 'بهروزرسانیها و اطلاعیهها',
|
||||
target: Uri.parse('https://t.me/ReelBoxApp'),
|
||||
isAdvertisement: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Only configured advertisements are parsed. The official Telegram entry is
|
||||
/// fixed in the client and can never be replaced or relabelled by server data.
|
||||
List<HomeBanner> parseHomeBanners(dynamic raw) {
|
||||
final result = <HomeBanner>[HomeBanner.telegram];
|
||||
if (raw is! Map || raw['banners'] is! List) return result;
|
||||
for (final row in (raw['banners'] as List).take(20)) {
|
||||
if (result.length == 6) break;
|
||||
if (row is! Map) continue;
|
||||
final en = _label(row['title_en'], 80);
|
||||
final fa = _label(row['title_fa'] ?? row['title_en'], 80);
|
||||
final subtitleEn = _label(row['subtitle_en'] ?? '', 180);
|
||||
final subtitleFa = _label(
|
||||
row['subtitle_fa'] ?? row['subtitle_en'] ?? '',
|
||||
180,
|
||||
);
|
||||
final target = _https(row['target_url']);
|
||||
final imageValue = row['image_url'];
|
||||
final image = imageValue == null || imageValue == ''
|
||||
? null
|
||||
: _https(imageValue);
|
||||
if (en == null ||
|
||||
en.isEmpty ||
|
||||
fa == null ||
|
||||
fa.isEmpty ||
|
||||
subtitleEn == null ||
|
||||
subtitleFa == null ||
|
||||
target == null ||
|
||||
(imageValue != null && imageValue != '' && image == null)) {
|
||||
continue;
|
||||
}
|
||||
result.add(
|
||||
HomeBanner._(
|
||||
titleEn: en,
|
||||
titleFa: fa,
|
||||
subtitleEn: subtitleEn,
|
||||
subtitleFa: subtitleFa,
|
||||
target: target,
|
||||
image: image,
|
||||
isAdvertisement: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return List.unmodifiable(result);
|
||||
}
|
||||
|
||||
String? _label(dynamic value, int limit) {
|
||||
if (value is! String ||
|
||||
value.length > limit ||
|
||||
RegExp(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]').hasMatch(value) ||
|
||||
!ContentPolicy.allowsText(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
Uri? _https(dynamic value) {
|
||||
if (value is! String ||
|
||||
value.length > 2048 ||
|
||||
RegExp(r'[\x00-\x20\x7f\\]').hasMatch(value) ||
|
||||
RegExp(
|
||||
r'%(?![0-9a-fA-F]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)',
|
||||
caseSensitive: false,
|
||||
).hasMatch(value) ||
|
||||
!ContentPolicy.allowsUrl(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final uri = Uri.parse(value);
|
||||
if (uri.scheme != 'https' ||
|
||||
uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.port != 443 ||
|
||||
uri.host.length > 253 ||
|
||||
!uri.host
|
||||
.split('.')
|
||||
.every(
|
||||
(part) =>
|
||||
RegExp(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$')
|
||||
.hasMatch(part),
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
return uri;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses only the existing trusted metadata transport, never a config URL.
|
||||
class HomeBannerService {
|
||||
const HomeBannerService(this.api);
|
||||
final ApiService api;
|
||||
Future<List<HomeBanner>> load() async {
|
||||
try {
|
||||
return parseHomeBanners(await api.get('banners/'));
|
||||
} catch (_) {
|
||||
// Announcements remain reachable when the metadata service is offline.
|
||||
return [HomeBanner.telegram];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/// Public service metadata. Source visibility is not an authorization grant.
|
||||
library;
|
||||
|
||||
import 'content_policy.dart';
|
||||
|
||||
Map<String, dynamic> _cleanJson(Map<String, dynamic> value) {
|
||||
final clean = ContentPolicy.sanitizeJson(value);
|
||||
return clean is Map ? Map<String, dynamic>.from(clean) : <String, dynamic>{};
|
||||
}
|
||||
|
||||
String _text(dynamic value, [String fallback = '']) =>
|
||||
value is String || value is num || value is bool ? '$value' : fallback;
|
||||
int _integer(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.isFinite ? value.toInt() : 0;
|
||||
return int.tryParse(_text(value).trim()) ?? 0;
|
||||
}
|
||||
|
||||
double _decimal(dynamic value) {
|
||||
final parsed = value is num
|
||||
? value.toDouble()
|
||||
: double.tryParse(_text(value).trim());
|
||||
return parsed != null && parsed.isFinite ? parsed : 0;
|
||||
}
|
||||
|
||||
List<T> _records<T>(dynamic value, T Function(Map<String, dynamic>) parse) =>
|
||||
value is List
|
||||
? value
|
||||
.whereType<Map>()
|
||||
.map((item) => parse(Map<String, dynamic>.from(item)))
|
||||
.toList()
|
||||
: <T>[];
|
||||
|
||||
class Tag {
|
||||
final int id;
|
||||
final String title, image;
|
||||
const Tag({this.id = 0, this.title = '', this.image = ''});
|
||||
factory Tag.fromJson(Map<String, dynamic> json) => Tag(
|
||||
id: _integer(json['id']),
|
||||
title: ContentPolicy.sanitizeText(_text(json['title'])),
|
||||
image: ContentPolicy.allowsUrl(_text(json['image']))
|
||||
? _text(json['image'])
|
||||
: '',
|
||||
);
|
||||
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'image': image};
|
||||
}
|
||||
|
||||
class MediaSource {
|
||||
final int id;
|
||||
final String quality, type, url;
|
||||
const MediaSource({
|
||||
this.id = 0,
|
||||
this.quality = '',
|
||||
this.type = '',
|
||||
this.url = '',
|
||||
});
|
||||
factory MediaSource.fromJson(Map<String, dynamic> json) => MediaSource(
|
||||
id: _integer(json['id']),
|
||||
quality: ContentPolicy.sanitizeText(_text(json['quality'])),
|
||||
type: _text(json['type']),
|
||||
url: ContentPolicy.allowsUrl(_text(json['url'])) ? _text(json['url']) : '',
|
||||
);
|
||||
bool get isTrailer =>
|
||||
RegExp(
|
||||
r'تیزر|تریلر|trailer|teaser',
|
||||
caseSensitive: false,
|
||||
).hasMatch(quality) ||
|
||||
type.toLowerCase() == 'trailer';
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'quality': quality,
|
||||
'type': type,
|
||||
'url': url,
|
||||
};
|
||||
}
|
||||
|
||||
class EpisodeRecord {
|
||||
final int id;
|
||||
final String title, description, duration;
|
||||
final List<MediaSource> sources;
|
||||
final Map<String, dynamic> raw;
|
||||
const EpisodeRecord({
|
||||
this.id = 0,
|
||||
this.title = '',
|
||||
this.description = '',
|
||||
this.duration = '',
|
||||
this.sources = const [],
|
||||
this.raw = const {},
|
||||
});
|
||||
factory EpisodeRecord.fromJson(Map<String, dynamic> value) {
|
||||
final json = _cleanJson(value);
|
||||
return EpisodeRecord(
|
||||
id: _integer(json['id']),
|
||||
title: _text(json['title']),
|
||||
description: _text(json['description']),
|
||||
duration: _text(json['duration']),
|
||||
sources: _records(json['sources'], MediaSource.fromJson),
|
||||
raw: Map.unmodifiable(json),
|
||||
);
|
||||
}
|
||||
Map<String, dynamic> toJson() => {
|
||||
...raw,
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'duration': duration,
|
||||
'sources': sources.map((source) => source.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
class SeasonRecord {
|
||||
final int id;
|
||||
final String title;
|
||||
final List<EpisodeRecord> episodes;
|
||||
final Map<String, dynamic> raw;
|
||||
const SeasonRecord({
|
||||
this.id = 0,
|
||||
this.title = '',
|
||||
this.episodes = const [],
|
||||
this.raw = const {},
|
||||
});
|
||||
factory SeasonRecord.fromJson(Map<String, dynamic> value) {
|
||||
final json = _cleanJson(value);
|
||||
return SeasonRecord(
|
||||
id: _integer(json['id']),
|
||||
title: _text(json['title']),
|
||||
episodes: _records(json['episodes'], EpisodeRecord.fromJson),
|
||||
raw: Map.unmodifiable(json),
|
||||
);
|
||||
}
|
||||
Map<String, dynamic> toJson() => {
|
||||
...raw,
|
||||
'id': id,
|
||||
'title': title,
|
||||
'episodes': episodes.map((episode) => episode.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
class TitleRecord {
|
||||
final int id, year;
|
||||
final String type, title, description, duration, classification, image, cover;
|
||||
final double imdb, rating;
|
||||
final List<Tag> genres, countries;
|
||||
final List<MediaSource> sources;
|
||||
final Map<String, dynamic> raw;
|
||||
const TitleRecord({
|
||||
this.id = 0,
|
||||
this.type = 'movie',
|
||||
this.title = '',
|
||||
this.description = '',
|
||||
this.duration = '',
|
||||
this.classification = '',
|
||||
this.image = '',
|
||||
this.cover = '',
|
||||
this.year = 0,
|
||||
this.imdb = 0,
|
||||
this.rating = 0,
|
||||
this.genres = const [],
|
||||
this.countries = const [],
|
||||
this.sources = const [],
|
||||
this.raw = const {},
|
||||
});
|
||||
factory TitleRecord.fromJson(Map<String, dynamic> value) {
|
||||
final json = _cleanJson(value);
|
||||
return TitleRecord(
|
||||
id: _integer(json['id'] ?? json['posted_id'] ?? json['movie_id']),
|
||||
type: _text(json['type'] ?? json['kind'], 'movie'),
|
||||
title: _text(json['title'] ?? json['name']),
|
||||
description: _text(json['description']),
|
||||
duration: _text(json['duration']),
|
||||
classification: _text(json['classification']),
|
||||
image: _text(json['image'] ?? json['img'] ?? json['poster']),
|
||||
cover: _text(json['cover']),
|
||||
year: _integer(json['year']),
|
||||
imdb: _decimal(json['imdb']),
|
||||
rating: _decimal(json['rating']),
|
||||
genres: _records(json['genres'], Tag.fromJson),
|
||||
countries: _records(json['country'] ?? json['countries'], Tag.fromJson),
|
||||
sources: _records(json['sources'], MediaSource.fromJson),
|
||||
raw: Map.unmodifiable(json),
|
||||
);
|
||||
}
|
||||
bool get isSeries => type == 'serie' || type == 'series';
|
||||
bool get isChannel => type == 'channel';
|
||||
Map<String, dynamic> toJson() => {
|
||||
...raw,
|
||||
'id': id,
|
||||
'type': type,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'duration': duration,
|
||||
'classification': classification,
|
||||
'image': image,
|
||||
'cover': cover,
|
||||
'year': year,
|
||||
'imdb': imdb,
|
||||
'rating': rating,
|
||||
'genres': genres.map((tag) => tag.toJson()).toList(),
|
||||
'country': countries.map((tag) => tag.toJson()).toList(),
|
||||
'sources': sources.map((source) => source.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user