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
+8
View File
@@ -50,6 +50,14 @@ void main() {
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
});
test('requested search tags can match tag prefixes', () {
final post = _post(tags: ['zenless_zone_zero', 'ellen_joe']);
expect(postMatchesRequestedTags(post, ['zenless']), isTrue);
expect(postMatchesRequestedTags(post, ['zenless', 'ellen']), isTrue);
expect(postMatchesRequestedTags(post, ['zone']), isFalse);
});
test('hidden post keys are filtered locally', () {
final post = _post(tags: ['touhou']);
final settings = AppSettings.defaults.copyWith(
+22 -19
View File
@@ -38,9 +38,13 @@ void main() {
File('${Directory.current.path}${Platform.pathSeparator}isar.dll');
if (Platform.isWindows && !copiedIsarDll.existsSync()) {
final localAppData = Platform.environment['LOCALAPPDATA'];
final source = File(
final bundled = File(
'${Directory.current.path}\\third_party\\isar_flutter_libs\\windows\\isar.dll',
);
final cached = File(
'$localAppData\\Pub\\Cache\\hosted\\pub.dev\\isar_flutter_libs-3.1.0+1\\windows\\isar.dll',
);
final source = bundled.existsSync() ? bundled : cached;
if (source.existsSync()) {
await source.copy(copiedIsarDll.path);
}
@@ -85,26 +89,24 @@ void main() {
'e926',
'realbooru',
'xbooru',
'cosbooru',
'paheal',
'kemono',
'coomer',
]);
expect(
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
isFalse,
);
expect(
providers.singleWhere((provider) => provider.id == 'cosbooru').enabled,
isTrue,
);
final realbooru =
providers.singleWhere((provider) => provider.id == 'realbooru');
expect(realbooru.enabled, isTrue);
expect(realbooru.apiType, 'realbooru_html');
expect(realbooru.baseUrl, 'https://realbooru.com');
expect(providers.where((provider) => provider.id == 'cosbooru'), isEmpty);
final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru');
expect(xbooru.enabled, isTrue);
expect(xbooru.baseUrl, 'https://xbooru.com');
expect(xbooru.apiType, 'gelbooru');
final cosbooru =
providers.singleWhere((provider) => provider.id == 'cosbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
expect(cosbooru.apiType, 'danbooru');
final paheal = providers.singleWhere((provider) => provider.id == 'paheal');
expect(paheal.enabled, isTrue);
expect(paheal.baseUrl, 'https://rule34.paheal.net');
expect(paheal.apiType, 'paheal');
final kemono = providers.singleWhere((provider) => provider.id == 'kemono');
final coomer = providers.singleWhere((provider) => provider.id == 'coomer');
expect(kemono.enabled, isTrue);
@@ -113,7 +115,8 @@ void main() {
expect(coomer.apiType, 'coomer');
});
test('provider repository migrates old CosBooru config', () async {
test('provider repository disables old CosBooru config without deleting it',
() async {
final repository = ProviderRepository(databaseService);
final now = DateTime(2026);
await repository.saveProvider(ContentProviderConfig(
@@ -134,10 +137,10 @@ void main() {
final cosbooru =
providers.singleWhere((provider) => provider.id == 'cosbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
expect(cosbooru.apiType, 'danbooru');
expect(cosbooru.enabled, isTrue);
expect(cosbooru.priority, 9);
expect(cosbooru.baseUrl, 'https://cos.booru.nl');
expect(cosbooru.apiType, 'gelbooru');
expect(cosbooru.enabled, isFalse);
expect(cosbooru.priority, 99);
});
test('settings saveEnabledProviders updates provider configs', () async {
+123
View File
@@ -84,6 +84,7 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider {
this.posts, {
this.failSearch = false,
this.suggestions = const [],
this.suggestionDelay = Duration.zero,
});
@override
@@ -95,6 +96,8 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider {
final List<Post> posts;
final bool failSearch;
final List<TagSuggestion> suggestions;
final Duration suggestionDelay;
int suggestionCalls = 0;
@override
Future<ProviderHealth> checkHealth() async => ProviderHealth(
@@ -123,6 +126,10 @@ class FakeProvider implements ContentProvider, TagSuggestionProvider {
@override
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
suggestionCalls++;
if (suggestionDelay > Duration.zero) {
await Future<void>.delayed(suggestionDelay);
}
return suggestions
.where((suggestion) => suggestion.name.startsWith(query))
.take(limit)
@@ -303,4 +310,120 @@ void main() {
'e926',
});
});
test('tag suggestions timeout slow providers without blocking fast results',
() async {
final repository = FakeProviderRepository()
..configs['fast'] = config('fast', 0)
..configs['slow'] = config('slow', 1);
final fast = FakeProvider(
'fast',
'fast',
[],
suggestions: const [
TagSuggestion(
name: 'cat_fast',
category: TagCategory.general,
postCount: 20,
providerId: 'fast',
),
],
);
final slow = FakeProvider(
'slow',
'slow',
[],
suggestionDelay: const Duration(seconds: 3),
suggestions: const [
TagSuggestion(
name: 'cat_slow',
category: TagCategory.general,
postCount: 200,
providerId: 'slow',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'fast': fast, 'slow': slow}),
);
final startedAt = DateTime.now();
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(DateTime.now().difference(startedAt),
lessThan(const Duration(milliseconds: 2500)));
expect(result.data.map((item) => item.name), ['cat_fast']);
});
test('tag suggestions dedupe by name and keep highest post count', () async {
final repository = FakeProviderRepository()
..configs['a'] = config('a', 0)
..configs['b'] = config('b', 1);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
),
'b': FakeProvider(
'b',
'b',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.artist,
postCount: 100,
providerId: 'b',
),
],
),
}),
);
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(result.data, hasLength(1));
expect(result.data.single.providerId, 'b');
expect(result.data.single.postCount, 100);
});
test('tag suggestions cache repeated prefix results', () async {
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
final provider = FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'a': provider}),
);
await manager.suggestTags('cat', limit: 4);
await manager.suggestTags('cat', limit: 4);
expect(provider.suggestionCalls, 1);
});
}
+21 -22
View File
@@ -5,10 +5,10 @@ import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart';
import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart';
import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/danbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/custom_provider.dart';
import 'package:gel_rule_app/backend/providers/kemono_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/backend/providers/realbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/realbooru_html_provider.dart';
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
void main() {
@@ -231,31 +231,30 @@ void main() {
expect(posts.single.rating, 'unknown');
});
test('seed providers include CosBooru and no Paheal', () {
test('seed providers include Realbooru HTML Paheal and exclude CosBooru', () {
final paheal = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'paheal')
.single;
expect(paheal.enabled, isTrue);
expect(paheal.apiType, 'paheal');
expect(paheal.baseUrl, 'https://rule34.paheal.net');
final realbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'realbooru')
.single;
expect(realbooru.enabled, isFalse);
expect(realbooru.apiType, 'realbooru');
expect(realbooru.enabled, isTrue);
expect(realbooru.apiType, 'realbooru_html');
expect(realbooru.baseUrl, 'https://realbooru.com');
expect(
ProviderRepository.seedProviders()
.where((provider) => provider.id == 'paheal'),
.where((provider) => provider.id == 'cosbooru'),
isEmpty,
);
final cosbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'cosbooru')
.single;
final xbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'xbooru')
.single;
expect(xbooru.enabled, isTrue);
expect(xbooru.apiType, 'gelbooru');
expect(xbooru.baseUrl, 'https://xbooru.com');
expect(cosbooru.enabled, isTrue);
expect(cosbooru.apiType, 'danbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
final kemono = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'kemono')
.single;
@@ -268,7 +267,7 @@ void main() {
expect(coomer.apiType, 'coomer');
});
test('provider factory creates Realbooru provider', () {
test('provider factory treats Realbooru api type as unsupported', () {
final now = DateTime(2026);
final provider = ProviderFactory().create(ContentProviderConfig(
id: 'realbooru',
@@ -283,25 +282,25 @@ void main() {
updatedAt: now,
));
expect(provider, isA<RealbooruProvider>());
expect(provider, isA<UnsupportedCustomProvider>());
});
test('provider factory creates CosBooru as Danbooru provider', () {
test('provider factory creates Realbooru HTML provider', () {
final now = DateTime(2026);
final provider = ProviderFactory().create(ContentProviderConfig(
id: 'cosbooru',
name: 'CosBooru',
baseUrl: 'https://cos.lycore.co',
apiType: 'danbooru',
id: 'realbooru',
name: 'Realbooru',
baseUrl: 'https://realbooru.com',
apiType: 'realbooru_html',
enabled: true,
priority: 8,
priority: 7,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
));
expect(provider, isA<DanbooruProvider>());
expect(provider, isA<RealbooruHtmlProvider>());
});
test('provider factory creates Kemono and Coomer artist providers', () {
+2 -2
View File
@@ -14,10 +14,10 @@ class FakeSearchRepository implements SearchRepository {
}
@override
Future<Result<List<SearchHistory>>> recent({int limit = 20}) async {
Future<Result<List<SearchHistory>>> recent({int? limit}) async {
final sorted = [...items]
..sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
return Success(sorted.take(limit).toList());
return Success((limit == null ? sorted : sorted.take(limit)).toList());
}
@override