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 _legacy(String file, Map query) => api.get('${api.hostRoot}/api-user/$file', query: query); Future suggestions({int page = 1}) async => _preservePage(await api.bestRandom(page: page), page); Future 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 rows, int page) => CatalogPage( rows, nextPage: rows is CatalogPage ? rows.nextPage : rows.isEmpty ? null : page + 1, ); Future 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 sharedTitles({int page = 1, bool newest = false}) async { final rows = discoveryRows( await _legacy('filmahang/star.php', { 'action': newest ? 'new' : 'share', 'pageno': page, }), ); final titles = []; final ids = {}; // 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> { DiscoveryObjectPage( Iterable> rows, { required this.nextPage, }) : _rows = List.unmodifiable(rows); final List> _rows; final int? nextPage; @override int get length => _rows.length; @override set length(int value) => throw UnsupportedError('Read-only page'); @override Map operator [](int index) => _rows[index]; @override void operator []=(int index, Map 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 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 actorMetadata(Map row) { final nested = row['actor']; final person = nested is Map ? Map.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 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> 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((row) => Map.from(row)) .toList(); }