feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements

This commit is contained in:
2026-09-03 16:40:37 +03:00
parent 7cccb2cd8d
commit 45faa12133
55 changed files with 4263 additions and 2076 deletions
@@ -24,6 +24,14 @@ abstract class ContentProvider {
Future<ProviderHealth> checkHealth();
}
abstract class PostPageProvider {
String? postPageUrl(Post post);
}
abstract class MediaHeadersProvider {
Map<String, String> mediaHeaders(Post post);
}
abstract class CommentProvider {
Future<List<PostComment>> getComments(String postId);
}
+5 -1
View File
@@ -8,7 +8,8 @@ import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class DanbooruProvider implements ContentProvider, CommentProvider {
class DanbooruProvider
implements ContentProvider, CommentProvider, PostPageProvider {
DanbooruProvider({
required this.id,
required this.name,
@@ -27,6 +28,9 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
final Dio _dio;
final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+8 -1
View File
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
import 'content_provider.dart';
class E621Provider
implements ContentProvider, TagSuggestionProvider, CommentProvider {
implements
ContentProvider,
TagSuggestionProvider,
CommentProvider,
PostPageProvider {
E621Provider({
required this.id,
required this.name,
@@ -29,6 +33,9 @@ class E621Provider
final Dio _dio;
final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+17 -1
View File
@@ -15,7 +15,9 @@ class GelbooruProvider
ContentProvider,
CommentProvider,
TagSuggestionProvider,
TagMetadataProvider {
TagMetadataProvider,
PostPageProvider,
MediaHeadersProvider {
GelbooruProvider({
required this.id,
required this.name,
@@ -36,6 +38,20 @@ class GelbooruProvider
Dio get dio => _dio;
Map<String, String> get queryParameters => _queryParameters;
@override
String postPageUrl(Post post) =>
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
@override
Map<String, String> mediaHeaders(Post post) {
final host = Uri.tryParse(baseUrl)?.host;
return {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/',
};
}
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+41 -6
View File
@@ -106,10 +106,15 @@ class KemonoProvider
'itemsPerPage': limit.clamp(1, 100),
},
);
final artists = _artistsFromResponse(response.data)
.where((artist) =>
service == null || service.isEmpty || artist.service == service)
.toList(growable: false);
final artists = _rankArtists(
_artistsFromResponse(response.data)
.where((artist) =>
service == null ||
service.isEmpty ||
artist.service == service)
.toList(growable: false),
searchText,
);
if (artists.isNotEmpty) return artists;
} catch (_) {
// Fall through to the indexed creator endpoints.
@@ -126,7 +131,7 @@ class KemonoProvider
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
},
);
return _artistsFromResponse(response.data);
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
} catch (_) {
return _listArtistsFromPublicApi(
service: service,
@@ -177,6 +182,7 @@ class KemonoProvider
));
}
if (mapped.isNotEmpty || posts.isEmpty) {
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return mapped.take(limit).toList(growable: false);
}
} catch (error) {
@@ -290,7 +296,36 @@ class KemonoProvider
artist.displayName.toLowerCase().contains(queryText) ||
artist.id.toLowerCase().contains(queryText)))
.toList(growable: false);
return items.skip(offset).take(limit).toList(growable: false);
final ranked = _rankArtists(items, queryText);
return ranked.skip(offset).take(limit).toList(growable: false);
}
List<ArtistProfile> _rankArtists(List<ArtistProfile> artists, String query) {
final queryText = query.trim().toLowerCase();
final ranked = [...artists];
ranked.sort((a, b) {
final scoreA = _artistSearchScore(a, queryText);
final scoreB = _artistSearchScore(b, queryText);
if (scoreA != scoreB) return scoreB.compareTo(scoreA);
final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
final updated = updatedB.compareTo(updatedA);
if (updated != 0) return updated;
final count = (b.postCount ?? 0).compareTo(a.postCount ?? 0);
if (count != 0) return count;
return a.displayName.compareTo(b.displayName);
});
return ranked;
}
int _artistSearchScore(ArtistProfile artist, String query) {
if (query.isEmpty) return 0;
final name = artist.displayName.toLowerCase();
final id = artist.id.toLowerCase();
if (name == query || id == query) return 100;
if (name.startsWith(query) || id.startsWith(query)) return 70;
if (name.contains(query) || id.contains(query)) return 40;
return 0;
}
List<dynamic> _postItems(dynamic data) {
+8 -1
View File
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
import 'content_provider.dart';
class MoebooruProvider
implements ContentProvider, TagSuggestionProvider, CommentProvider {
implements
ContentProvider,
TagSuggestionProvider,
CommentProvider,
PostPageProvider {
MoebooruProvider({
required this.id,
required this.name,
@@ -29,6 +33,9 @@ class MoebooruProvider
final Dio _dio;
final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+12 -4
View File
@@ -7,8 +7,9 @@ import 'e621_provider.dart';
import 'gelbooru_provider.dart';
import 'kemono_provider.dart';
import 'moebooru_provider.dart';
import 'realbooru_provider.dart';
import 'realbooru_html_provider.dart';
import 'rule34_provider.dart';
import 'rule34_paheal_provider.dart';
class ProviderFactory {
ContentProvider create(ContentProviderConfig config) {
@@ -41,13 +42,20 @@ class ProviderFactory {
dioClient: client,
queryParameters: queryParameters,
);
case 'realbooru':
return RealbooruProvider(
case 'paheal':
case 'rule34_paheal':
return Rule34PahealProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
);
case 'realbooru_html':
return RealbooruHtmlProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
);
case 'danbooru':
return DanbooruProvider(
+284 -25
View File
@@ -18,12 +18,30 @@ class ProviderManager {
final ProviderRepository _repository;
final ProviderFactory _factory;
final Map<String, _SuggestionCacheEntry> _suggestionCache = {};
final Set<String> _softRetryKeys = {};
final _softRetryController =
StreamController<ProviderSoftRetryResult>.broadcast();
_SuggestionProviderCacheEntry? _suggestionProviderCache;
static const _suggestionCacheTtl = Duration(seconds: 45);
static const _suggestionProviderCacheTtl = Duration(seconds: 60);
static const _suggestionTimeout = Duration(milliseconds: 1500);
Stream<ProviderSoftRetryResult> get softRetryResults =>
_softRetryController.stream;
Future<Result<List<ContentProviderConfig>>> loadConfigs({
bool enabledOnly = true,
}) async {
await _repository.ensureSeedProviders();
return _repository.getProviders(enabledOnly: enabledOnly);
final result = await _repository.getProviders(enabledOnly: enabledOnly);
if (result is Error<List<ContentProviderConfig>>) return result;
final configs = (result as Success<List<ContentProviderConfig>>)
.data
.where((config) => !_isLegacyRemovedConfig(config))
.toList(growable: false);
return Success(configs);
}
Future<Result<List<ContentProviderConfig>>> loadFeedConfigs({
@@ -97,20 +115,34 @@ class ProviderManager {
Failure(code: 'not_found', message: 'Provider not found'),
);
}
return _repository.saveProvider(
return _repository
.saveProvider(
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
);
)
.then((result) {
_clearSuggestionCaches();
return result;
});
},
onError: Error<void>.new,
);
}
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
return _repository
.saveProvider(config.copyWith(updatedAt: DateTime.now()))
.then((result) {
_clearSuggestionCaches();
return result;
});
}
Future<Result<void>> deleteProvider(String id) =>
_repository.deleteProvider(id);
Future<Result<void>> deleteProvider(String id) {
return _repository.deleteProvider(id).then((result) {
_clearSuggestionCaches();
return result;
});
}
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders();
@@ -186,6 +218,14 @@ class ProviderManager {
errorMessage: 'Search failed',
),
);
_scheduleSoftSearchRetry(
provider,
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
);
}
}
final posts = providerId == null
@@ -228,6 +268,75 @@ class ProviderManager {
return hash;
}
void _scheduleSoftSearchRetry(
ContentProvider provider, {
required List<String> tags,
required int page,
required int limit,
required String? rating,
required TopPeriodFilter topPeriod,
}) {
final key = [
provider.id,
page,
limit,
rating ?? '',
topPeriod.name,
...tags,
].join('\u001f');
if (!_softRetryKeys.add(key)) return;
unawaited(Future<void>.delayed(const Duration(milliseconds: 900), () async {
try {
final posts = await provider.searchPosts(
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
);
await _repository.saveDiagnostics(
ProviderDiagnostics(
providerId: provider.id,
lastSearchAt: DateTime.now(),
lastResultCount: posts.length,
),
);
await _repository.saveHealth(
ProviderHealth(
providerId: provider.id,
status: ProviderStatus.online,
pingMs: 0,
lastCheckedAt: DateTime.now(),
),
);
if (posts.isNotEmpty) {
_softRetryController.add(
ProviderSoftRetryResult(
providerId: provider.id,
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
posts: posts,
),
);
}
} catch (_) {
await _repository.saveDiagnostics(
ProviderDiagnostics(
providerId: provider.id,
lastSearchAt: DateTime.now(),
lastResultCount: 0,
lastErrorMessage: 'Search failed after retry',
),
);
} finally {
_softRetryKeys.remove(key);
}
}));
}
Future<Result<List<PostComment>>> getComments(
String providerId,
String postId,
@@ -282,6 +391,39 @@ class ProviderManager {
}
}
Future<Result<String?>> getPostPageUrl(Post post) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
final matches =
providers.where((provider) => provider.id == post.providerId);
if (matches.isEmpty || matches.first is! PostPageProvider) {
return const Success(null);
}
return Success((matches.first as PostPageProvider).postPageUrl(post));
}
Future<Result<Map<String, String>>> getMediaHeaders(Post post) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
final matches =
providers.where((provider) => provider.id == post.providerId);
if (matches.isNotEmpty && matches.first is MediaHeadersProvider) {
return Success(
(matches.first as MediaHeadersProvider).mediaHeaders(post),
);
}
return const Success({
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
});
}
Future<Result<Post>> enrichPostTags(Post post) async {
final hasUsefulGroups = post.tagGroups.entries.any(
(entry) => entry.key != 'general' && entry.value.isNotEmpty,
@@ -313,29 +455,106 @@ class ProviderManager {
String query, {
int limit = 20,
}) async {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return const Success([]);
final providersResult = await activeSuggestionProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
if (providers.isEmpty) return const Success([]);
final cappedLimit = limit.clamp(1, 50);
final providerIds = providers.map((provider) => provider.id).join(',');
final cacheKey = '$normalizedQuery|$providerIds|$cappedLimit';
final cached = _suggestionCache[cacheKey];
if (cached != null &&
DateTime.now().difference(cached.createdAt) < _suggestionCacheTtl) {
return Success(cached.items.take(cappedLimit).toList(growable: false));
}
final providerResults = await Future.wait(
providers.map(
(provider) => _suggestionsFromProvider(
provider as TagSuggestionProvider,
normalizedQuery,
cappedLimit,
),
),
);
final suggestions = <String, TagSuggestion>{};
for (final items in providerResults) {
for (final item in items) {
final nameKey = item.name.trim().toLowerCase();
if (nameKey.isEmpty) continue;
final current = suggestions[nameKey];
if (current == null || item.postCount > current.postCount) {
suggestions[nameKey] = item;
}
}
}
final values = suggestions.values.toList()
..sort((a, b) {
final count = b.postCount.compareTo(a.postCount);
return count != 0 ? count : a.name.compareTo(b.name);
});
_suggestionCache[cacheKey] = _SuggestionCacheEntry(
DateTime.now(),
values,
);
_pruneSuggestionCache();
return Success(values.take(cappedLimit).toList());
}
Future<List<TagSuggestion>> _suggestionsFromProvider(
TagSuggestionProvider provider,
String query,
int limit,
) async {
try {
return await provider
.suggestTags(query, limit: limit)
.timeout(_suggestionTimeout);
} catch (_) {
// Suggestions are non-critical; a failed provider should not affect UI.
return const [];
}
}
void _pruneSuggestionCache() {
final now = DateTime.now();
_suggestionCache.removeWhere(
(_, entry) => now.difference(entry.createdAt) >= _suggestionCacheTtl,
);
}
Future<Result<List<ContentProvider>>> activeSuggestionProviders() async {
final cached = _suggestionProviderCache;
if (cached != null &&
DateTime.now().difference(cached.createdAt) <
_suggestionProviderCacheTtl) {
return Success(cached.providers);
}
final providersResult = await activeFeedProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>)
.data
.whereType<TagSuggestionProvider>()
.toList();
final suggestions = <String, TagSuggestion>{};
for (final provider in providers) {
try {
final items = await provider.suggestTags(query, limit: limit);
for (final item in items) {
suggestions.putIfAbsent(
'${item.providerId}:${item.name}', () => item);
}
} catch (_) {
// Suggestions are non-critical; a failed provider should not affect UI.
}
}
final values = suggestions.values.toList()
..sort((a, b) => b.postCount.compareTo(a.postCount));
return Success(values.take(limit).toList());
final providers = <ContentProvider>[
for (final provider
in (providersResult as Success<List<ContentProvider>>).data)
if (provider is TagSuggestionProvider) provider,
];
_suggestionProviderCache = _SuggestionProviderCacheEntry(
DateTime.now(),
providers,
);
return Success(providers);
}
void _clearSuggestionCaches() {
_suggestionCache.clear();
_suggestionProviderCache = null;
}
Future<List<R>> _runLimited<T, R>(
@@ -367,4 +586,44 @@ class ProviderManager {
static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config);
}
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
final id = config.id.toLowerCase();
final type = config.apiType.toLowerCase();
return id == 'cosbooru' || type == 'realbooru';
}
}
class _SuggestionCacheEntry {
const _SuggestionCacheEntry(this.createdAt, this.items);
final DateTime createdAt;
final List<TagSuggestion> items;
}
class _SuggestionProviderCacheEntry {
const _SuggestionProviderCacheEntry(this.createdAt, this.providers);
final DateTime createdAt;
final List<ContentProvider> providers;
}
class ProviderSoftRetryResult {
const ProviderSoftRetryResult({
required this.providerId,
required this.tags,
required this.page,
required this.limit,
required this.topPeriod,
required this.posts,
this.rating,
});
final String providerId;
final List<String> tags;
final int page;
final int limit;
final TopPeriodFilter topPeriod;
final String? rating;
final List<Post> posts;
}
@@ -0,0 +1,341 @@
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<List<Post>> searchPosts({
required List<String> 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<String>(
'/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<Post?> getPost(String id) async {
final response = await _dio.get<String>(
'/index.php',
queryParameters: {'page': 'post', 's': 'view', 'id': id},
options: Options(responseType: ResponseType.plain),
);
return _parseDetails(id, response.data ?? '');
}
@override
Future<ProviderHealth> 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<String, String> 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<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final response = await _dio.get<dynamic>(
'/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<Post> _parseList(String html) {
final posts = <Post>[];
final pattern = RegExp(
r'''<div[^>]*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?<a[^>]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?<img[^>]+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'''<a[^>]+href=["']([^"']+)["'][^>]*>\s*Original\s*</a>''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final videoSources = RegExp(
r'''<source[^>]+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'''<input[^>]+id=["']source["'][^>]*>''', caseSensitive: false)
.firstMatch(html)
?.group(0) ??
'',
'value',
);
final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty
? _attribute(imageTag ?? '', 'alt')
: RegExp(
r'''<textarea[^>]+id=["']tags["'][^>]*>([\s\S]*?)</textarea>''',
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<String> _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<String, List<String>> _tagGroups(List<String> 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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
List<String> _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 [];
}
}
@@ -1,126 +0,0 @@
import '../../core/errors/app_exception.dart';
import '../mappers/gelbooru_mapper.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'gelbooru_provider.dart';
class RealbooruProvider extends GelbooruProvider {
RealbooruProvider({
required super.id,
required super.name,
required super.baseUrl,
required super.dioClient,
super.queryParameters,
});
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final queryTags = [
...tags,
if (rating != null && rating.isNotEmpty) 'rating:$rating',
if (topPeriod == TopPeriodFilter.allTime) 'sort:score:desc',
];
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'pid': page,
'limit': limit.clamp(1, 100),
'tags': queryTags.join(' '),
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
return GelbooruMapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
}
@override
Future<Post?> getPost(String id) async {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'id': id,
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
final posts = GelbooruMapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'limit': 1,
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
return ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'realbooru-dapi',
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: 'realbooru-dapi',
);
}
}
void _throwIfSearchFailed(dynamic data) {
if (data is Map) {
final success = data['success']?.toString().toLowerCase();
if (success == 'false') {
throw ProviderUnavailableException(
(data['message'] ??
data['reason'] ??
'Realbooru search is unavailable')
.toString(),
);
}
}
final text = data?.toString().toLowerCase() ?? '';
if (text.contains('success="false"') || text.contains("success='false'")) {
final message = RegExp(r'''(?:message|reason)\s*=\s*["']([^"']+)["']''')
.firstMatch(data?.toString() ?? '');
throw ProviderUnavailableException(
message?.group(1) ?? 'Realbooru search is unavailable',
);
}
}
}
@@ -0,0 +1,239 @@
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class Rule34PahealProvider implements ContentProvider, PostPageProvider {
Rule34PahealProvider({
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<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
// Paheal's public HTML list ignores common sort/top query parameters and
// exposes no stable top-period endpoint, so non-latest filters fall back
// to the regular list instead of pretending to support ranking.
final query = tags
.where((tag) => tag.trim().isNotEmpty && tag.trim() != 'all')
.map((tag) => tag.trim().replaceAll(' ', '_'))
.join('_');
final path = query.isEmpty
? '/post/list/${page + 1}'
: '/post/list/$query/${page + 1}';
final response = await _dio.get<String>(
path,
options: Options(responseType: ResponseType.plain),
);
return _parseList(response.data ?? '').take(limit).toList(growable: false);
}
@override
Future<Post?> getPost(String id) async {
final response = await _dio.get<String>(
'/post/view/$id',
options: Options(responseType: ResponseType.plain),
);
return _parseSingle(id, response.data ?? '');
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final posts = await searchPosts(tags: const [], 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: 'paheal-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(),
apiVersion: 'paheal-html',
);
}
}
@override
String postPageUrl(Post post) => '$baseUrl/post/view/${post.id}';
List<Post> _parseList(String html) {
final posts = <Post>[];
final pattern = RegExp(
r'''<div[^>]+class=['"][^'"]*\bshm-thumb\b[^'"]*['"][\s\S]*?</div>''',
caseSensitive: false,
);
for (final match in pattern.allMatches(html)) {
final block = match.group(0) ?? '';
final postId = _attribute(block, 'data-post-id');
final fileUrl = _absolute(
RegExp(
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*File Only\s*</a>''',
caseSensitive: false,
).firstMatch(block)?.group(1) ??
'',
);
final previewUrl = _absolute(
RegExp(r'''<img[^>]+src=['"]([^'"]+)['"]''', caseSensitive: false)
.firstMatch(block)
?.group(1) ??
'',
);
if (postId.isEmpty || (fileUrl.isEmpty && previewUrl.isEmpty)) continue;
final tags = _tags(_attribute(block, 'data-tags'));
final ext = _attribute(block, 'data-ext');
final dimensions = _dimensions(_attribute(block, 'title'));
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
posts.add(
Post(
id: postId,
providerId: id,
providerName: name,
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
sampleUrl: mediaUrl,
fileUrl: mediaUrl,
tags: tags,
rating: 'explicit',
width: dimensions.$1,
height: dimensions.$2,
source: '$baseUrl/post/view/$postId',
createdAt: DateTime.now(),
fileType: _fileType('$ext $mediaUrl'),
score: 0,
tagGroups: tags.isEmpty ? const {} : {'general': tags},
),
);
}
if (posts.isNotEmpty) return posts;
final singleId = RegExp(r'''/post/view/(\d+)''').firstMatch(html)?.group(1);
final single = singleId == null ? null : _parseSingle(singleId, html);
return single == null ? const [] : [single];
}
Post? _parseSingle(String postId, String html) {
final fileUrl = _absolute(
RegExp(
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*(?:File|Image) Only\s*</a>''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
RegExp(
r'''<(?:img|video|source)[^>]+src=['"]([^'"]+(?:jpg|jpeg|png|gif|webm|mp4)[^'"]*)['"]''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final previewUrl = _absolute(
RegExp(
r'''<meta[^>]+property=['"]og:image['"][^>]+content=['"]([^'"]+)['"]''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final tagsText = RegExp(
r'''<meta[^>]+name=['"]keywords['"][^>]+content=['"]([^'"]+)['"]''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'';
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
if (mediaUrl.isEmpty) return null;
final tags = _tags(tagsText);
return Post(
id: postId,
providerId: id,
providerName: name,
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
sampleUrl: mediaUrl,
fileUrl: mediaUrl,
tags: tags,
rating: 'explicit',
width: 0,
height: 0,
source: '$baseUrl/post/view/$postId',
createdAt: DateTime.now(),
fileType: _fileType(mediaUrl),
score: 0,
tagGroups: tags.isEmpty ? const {} : {'general': tags},
);
}
String _attribute(String source, String name) {
return _decode(
RegExp(
'''$name\\s*=\\s*['"]([^'"]*)['"]''',
caseSensitive: false,
).firstMatch(source)?.group(1) ??
'',
);
}
(int, int) _dimensions(String value) {
final match = RegExp(r'(\d+)x(\d+)').firstMatch(value);
return (
int.tryParse(match?.group(1) ?? '') ?? 0,
int.tryParse(match?.group(2) ?? '') ?? 0,
);
}
List<String> _tags(String value) {
return _decode(value)
.replaceAll(',', ' ')
.split(RegExp(r'\s+'))
.map((tag) => tag.trim().replaceAll(' ', '_').toLowerCase())
.where((tag) => tag.isNotEmpty)
.toSet()
.toList(growable: false);
}
String _fileType(String value) {
final lower = value.toLowerCase().split('?').first;
if (lower.contains('webm') || lower.contains('mp4')) return 'video';
if (lower.contains('gif')) return 'gif';
if (lower.contains('png')) return 'png';
return 'image';
}
String _absolute(String url) {
final value = _decode(url.trim());
if (value.isEmpty) return '';
if (value.startsWith('//')) return 'https:$value';
if (value.startsWith('/')) return '$baseUrl$value';
return value;
}
String _decode(String value) {
return value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
}
@@ -12,6 +12,17 @@ class Rule34Provider extends GelbooruProvider {
super.queryParameters,
});
@override
String postPageUrl(Post post) =>
'https://rule34.xxx/index.php?page=post&s=view&id=${post.id}';
@override
Map<String, String> mediaHeaders(Post post) => const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
'Referer': 'https://rule34.xxx/',
};
@override
Future<List<Post>> searchPosts({
required List<String> tags,