import 'dart:convert'; import 'package:dio/dio.dart'; import '../../core/http/dio_client.dart'; import '../models/post.dart'; import '../models/provider_health.dart'; import '../models/tag_suggestion.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; class RealbooruHtmlProvider implements ContentProvider, PostPageProvider, MediaHeadersProvider, TagSuggestionProvider { RealbooruHtmlProvider({ required this.id, required this.name, required this.baseUrl, required DioClient dioClient, }) : _dio = dioClient.dio; @override final String id; @override final String name; @override final String baseUrl; final Dio _dio; @override Future> searchPosts({ required List tags, required int page, int limit = 50, String? rating, TopPeriodFilter topPeriod = TopPeriodFilter.none, }) async { final queryTags = [ if (tags.isEmpty) 'all' else ...tags, if (rating != null && rating.isNotEmpty) 'rating:$rating', ].join(' '); final response = await _dio.get( '/index.php', queryParameters: { 'page': 'post', 's': 'list', 'tags': queryTags, 'pid': page * limit, }, options: Options(responseType: ResponseType.plain), ); return _parseList(response.data ?? '').take(limit).toList(growable: false); } @override Future getPost(String id) async { final response = await _dio.get( '/index.php', queryParameters: {'page': 'post', 's': 'view', 'id': id}, options: Options(responseType: ResponseType.plain), ); return _parseDetails(id, response.data ?? ''); } @override Future checkHealth() async { final startedAt = DateTime.now(); try { final posts = await searchPosts(tags: const ['all'], page: 0, limit: 1); return ProviderHealth( providerId: id, status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online, pingMs: DateTime.now().difference(startedAt).inMilliseconds, lastCheckedAt: DateTime.now(), apiVersion: 'realbooru-html', errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null, ); } catch (error) { return ProviderHealth( providerId: id, status: ProviderStatus.offline, pingMs: DateTime.now().difference(startedAt).inMilliseconds, lastCheckedAt: DateTime.now(), errorMessage: error.toString(), ); } } @override String postPageUrl(Post post) => '$baseUrl/index.php?page=post&s=view&id=${post.id}'; @override Map mediaHeaders(Post post) => { 'User-Agent': 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36', 'Accept': 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8', 'Referer': postPageUrl(post), }; @override Future> suggestTags(String query, {int limit = 20}) async { final trimmed = query.trim(); if (trimmed.isEmpty) return const []; final response = await _dio.get( '/index.php', queryParameters: { 'page': 'autocomplete', 'term': trimmed, }, ); final values = _autocompleteItems(response.data); return values .map((name) => TagSuggestion( name: name, category: TagCategory.unknown, postCount: 0, providerId: id, )) .where((item) => item.name.toLowerCase().startsWith( trimmed.toLowerCase(), )) .take(limit.clamp(1, 50)) .toList(growable: false); } List _parseList(String html) { final posts = []; final pattern = RegExp( r''']*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?]+src=["']([^"']+)["'][^>]*>''', caseSensitive: false, ); for (final match in pattern.allMatches(html)) { final id = match.group(1) ?? ''; final imgTag = match.group(0) ?? ''; final previewUrl = _absolute(_decode(match.group(3) ?? '')); final tags = _tags(_attribute(imgTag, 'title')); final isVideo = imgTag.contains('#0000ff') || tags.contains('webm') || tags.contains('video'); final isGif = !isVideo && (tags.contains('gif') || tags.contains('animated_gif')); final derivedOriginal = isVideo ? '' : _imageUrlFromThumbnail(previewUrl, gif: isGif); posts.add( Post( id: id, providerId: this.id, providerName: name, previewUrl: previewUrl, sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal, fileUrl: isVideo ? '' : derivedOriginal, tags: tags, rating: 'explicit', width: 0, height: 0, source: postPageUrl(Post( id: id, providerId: this.id, providerName: name, previewUrl: previewUrl, sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal, fileUrl: isVideo ? '' : derivedOriginal, tags: tags, rating: 'explicit', width: 0, height: 0, createdAt: DateTime.now(), fileType: isVideo ? 'video' : isGif ? 'gif' : 'image', score: 0, )), createdAt: DateTime.now(), fileType: isVideo ? 'video' : isGif ? 'gif' : 'image', score: 0, tagGroups: _tagGroups(tags), ), ); } return posts; } Post? _parseDetails(String postId, String html) { final imageTag = RegExp( r'''<(?:img|video)[^>]+(?:id=["']image["'][^>]*|[^>]*id=["']image["'])''', caseSensitive: false, ).firstMatch(html)?.group(0); final originalUrl = _absolute( RegExp( r''']+href=["']([^"']+)["'][^>]*>\s*Original\s*''', caseSensitive: false, ).firstMatch(html)?.group(1) ?? '', ); final videoSources = RegExp( r''']+src=["']([^"']+\.(?:webm|mp4)[^"']*)["']''', caseSensitive: false, ) .allMatches(html) .map((match) => _absolute(match.group(1) ?? '')) .where((url) => url.isNotEmpty) .toList(); videoSources.sort((left, right) { final leftWebm = left.toLowerCase().split('?').first.endsWith('.webm'); final rightWebm = right.toLowerCase().split('?').first.endsWith('.webm'); if (leftWebm == rightWebm) return 0; return leftWebm ? -1 : 1; }); final imageUrl = _absolute(_attribute(imageTag ?? '', 'src')); final mediaUrl = videoSources.isNotEmpty ? videoSources.first : originalUrl.isNotEmpty ? originalUrl : imageUrl; final sampleUrl = videoSources.length > 1 ? videoSources[1] : mediaUrl; final source = _attribute( RegExp(r''']+id=["']source["'][^>]*>''', caseSensitive: false) .firstMatch(html) ?.group(0) ?? '', 'value', ); final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty ? _attribute(imageTag ?? '', 'alt') : RegExp( r''']+id=["']tags["'][^>]*>([\s\S]*?)''', caseSensitive: false, ).firstMatch(html)?.group(1) ?? ''; final tags = _tags(tagsText); if (mediaUrl.isEmpty) return null; return Post( id: postId, providerId: id, providerName: name, previewUrl: imageUrl.isEmpty ? mediaUrl : imageUrl, sampleUrl: sampleUrl, fileUrl: mediaUrl, tags: tags, rating: 'explicit', width: 0, height: 0, source: source.isEmpty ? postPageUrlById(postId) : source, createdAt: DateTime.now(), fileType: _fileType(mediaUrl), score: 0, tagGroups: _tagGroups(tags), ); } String postPageUrlById(String postId) => '$baseUrl/index.php?page=post&s=view&id=$postId'; String _attribute(String source, String name) { final match = RegExp( '''$name\\s*=\\s*["']([^"']*)["']''', caseSensitive: false, ).firstMatch(source); return _decode(match?.group(1) ?? ''); } List _tags(String value) { return _decode(value) .replaceAll(',', ' ') .split(RegExp(r'\s+')) .map((tag) => tag.trim().replaceAll(' ', '_')) .where((tag) => tag.isNotEmpty) .toSet() .toList(growable: false); } Map> _tagGroups(List tags) => tags.isEmpty ? const {} : {'general': tags}; String _fileType(String url) { final lower = url.toLowerCase().split('?').first; if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video'; if (lower.endsWith('.gif')) return 'gif'; return 'image'; } String _absolute(String url) { final value = _decode(url.trim()); if (value.startsWith('//')) return _normalizeUrl('https:$value'); if (value.startsWith('/')) return _normalizeUrl('$baseUrl$value'); return _normalizeUrl(value); } String _normalizeUrl(String url) { final uri = Uri.tryParse(url); if (uri == null || !uri.hasScheme || uri.host.isEmpty) return url; final normalizedPath = uri.path.replaceFirst(RegExp(r'^/+'), '/'); return uri.replace(path: normalizedPath).toString(); } String _imageUrlFromThumbnail(String thumbnailUrl, {bool gif = false}) { final uri = Uri.tryParse(thumbnailUrl); if (uri == null) return ''; final match = RegExp(r'/thumbnails/([^/]+)/([^/]+)/thumbnail_([^/.]+)\.') .firstMatch(uri.path); if (match == null) return ''; final extension = gif ? 'gif' : 'jpeg'; final path = '/images/${match.group(1)}/${match.group(2)}/${match.group(3)}.$extension'; return uri.replace(path: path, query: '').toString(); } String _decode(String value) { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll(''', "'"); } List _autocompleteItems(dynamic data) { final source = data is String ? jsonDecode(data) : data; if (source is List) { return source .map((item) => item.toString().trim().replaceAll(' ', '_')) .where((item) => item.isNotEmpty) .toSet() .toList(growable: false); } return const []; } }