[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+643
View File
@@ -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.');
}
}
}
}
+191
View File
@@ -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();
}
+305
View File
@@ -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('&amp;', '&')
.replaceAll('&nbsp;', ' ');
}
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;
}
}
+132
View File
@@ -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];
}
}
}
+202
View File
@@ -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(),
};
}