import 'package:dio/dio.dart'; import '../source.dart'; import 'constants.dart'; import 'urls.dart'; /// ComicLand JSON adapter (port of cxyz `src/comicland/*`). class ComicLandSource implements ComicSource { ComicLandSource({Dio? dio}) : _dio = dio ?? Dio( BaseOptions( baseUrl: comicLandApiBase, connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 60), headers: comicLandApiHeaders, responseType: ResponseType.json, validateStatus: (s) => s != null && s >= 200 && s < 300, ), ); final Dio _dio; @override SourceId get id => SourceId.comicland; @override String get displayName => 'ComicLand'; @override String get blurb => 'Newest, popular, official, uncensored, recommended, and search via ComicLand API'; @override List get catalogMenus => const [ CatalogMenu(kind: CatalogMenuKind.latest, title: 'Newest'), CatalogMenu(kind: CatalogMenuKind.popular, title: 'Popular'), CatalogMenu(kind: CatalogMenuKind.official, title: 'Official'), CatalogMenu(kind: CatalogMenuKind.uncensored, title: 'Uncensored'), CatalogMenu(kind: CatalogMenuKind.recommended, title: 'Recommended'), CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'), ]; @override Map get imageHeaders => Map.unmodifiable(comicLandCdnHeaders); Map _unwrap(dynamic payload, String label) { if (payload is! Map) { throw StateError('ComicLand $label: empty response'); } final map = Map.from(payload); final code = map['code']; if (code != 0) { final msg = (map['message'] as String?)?.trim(); throw StateError( 'ComicLand $label failed: ${msg?.isNotEmpty == true ? msg : 'code $code'}', ); } final data = map['data']; if (data is! Map) { throw StateError('ComicLand $label: missing data'); } return Map.from(data); } Future> _get( String path, { Map? query, String label = 'request', }) async { final res = await _dio.get(path, queryParameters: query); return _unwrap(res.data, label); } List _listFrom(Map data, {String listKey = 'list'}) { final raw = data[listKey]; if (raw is! List) return const []; return raw .whereType() .map((e) => catalogFromJson(Map.from(e))) .toList(); } @override Future> loadCatalog( CatalogMenuKind kind, { String query = '', int offset = 0, int limit = 30, CatalogBrowseParams? browse, }) async { switch (kind) { case CatalogMenuKind.hotToday: case CatalogMenuKind.hotWeek: case CatalogMenuKind.hotMonth: case CatalogMenuKind.directory: throw UnsupportedError('ComicLand has no ${kind.name} catalog feed'); case CatalogMenuKind.popular: final data = await _get('/comics/popular', label: 'comics/popular'); final all = _listFrom(data); if (offset == 0 && all.length <= limit) return all; final end = (offset + limit).clamp(0, all.length); if (offset >= all.length) return const []; return all.sublist(offset, end); case CatalogMenuKind.latest: final data = await _get( '/comics', query: {'offset': offset, 'limit': limit, 'status': 'ongoing'}, label: 'comics/newest', ); return _listFrom(data); case CatalogMenuKind.official: final data = await _get( '/comics/official', query: {'offset': offset, 'limit': limit}, label: 'comics/official', ); return _listFrom(data); case CatalogMenuKind.uncensored: final data = await _get( '/comic/search', query: {'q': 'uncensored', 'offset': offset, 'limit': limit}, label: 'comic/search/uncensored', ); return _listFrom(data, listKey: 'items'); case CatalogMenuKind.recommended: final data = await _get( '/comics', query: {'offset': offset, 'limit': limit}, label: 'comics/recommended', ); return _listFrom(data); case CatalogMenuKind.search: final q = query.trim(); if (q.length < 2) return const []; final data = await _get( '/comic/search', query: {'q': q, 'offset': offset, 'limit': limit}, label: 'comic/search', ); return _listFrom(data, listKey: 'items'); } } @override Future fetchSeries(String listingUrl) async { final parts = parseComicLandUrl(listingUrl); final data = await _get( '/comic/detail', query: {'slug': parts.slug}, label: 'comic/detail', ); final title = (data['title'] as String?)?.trim().isNotEmpty == true ? (data['title'] as String).trim() : parts.slug; final rawChapters = data['chapters']; final chapters = []; if (rawChapters is List) { for (final row in rawChapters) { if (row is! Map) continue; final map = Map.from(row); final index = _chapterIndexString(map['chapter_index']); if (index == null) continue; final label = (map['title'] as String?)?.trim().replaceAll(RegExp(r'\s+'), ' '); final chapterTitle = (label != null && label.isNotEmpty) ? label : 'Chapter $index'; final url = chapterUrlFromParts(parts.origin, parts.slug, index); chapters.add( ChapterLink( url: url, title: chapterTitle, chapterKey: chapterKeyFromUrl(url), chapterNumber: chapterNumberFromTitle(chapterTitle) ?? index, ), ); } } chapters.sort((a, b) { final na = double.tryParse(Uri.parse(a.url).pathSegments.last); final nb = double.tryParse(Uri.parse(b.url).pathSegments.last); if (na != null && nb != null && na != nb) return na.compareTo(nb); return a.url.compareTo(b.url); }); final listing = listingUrlForSlug(parts.slug); return SeriesDetail( source: SourceId.comicland, listingUrl: listing, slug: parts.slug, title: title, coverUrl: data['cover_url'] as String?, description: data['description'] as String?, chapters: chapters, ); } @override Future> fetchPageUrls(String chapterUrl) async { final parts = parseComicLandUrl(chapterUrl); if (parts.chapterIndex == null) { throw StateError('ComicLand chapter URL is missing an index: $chapterUrl'); } final data = await _get( '/chapter/pages_by_index', query: {'slug': parts.slug, 'index': parts.chapterIndex}, label: 'chapter/pages_by_index', ); final pages = data['pages']; if (pages is! List) return const []; final urls = []; for (final raw in pages) { if (raw is! String || raw.trim().isEmpty) continue; try { urls.add(Uri.parse(raw.trim()).toString()); } catch (_) {} } return urls; } String? _chapterIndexString(dynamic rawIndex) { if (rawIndex is num && rawIndex.isFinite) { if (rawIndex == rawIndex.roundToDouble()) { return rawIndex.toInt().toString(); } return rawIndex.toString(); } if (rawIndex is String && rawIndex.trim().isNotEmpty) { return rawIndex.trim(); } return null; } }