feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user