feat: align with Gelbooru DAPI spec, optimize tag queries & batch categorization (v2.3.2)

This commit is contained in:
2026-09-04 12:54:41 +03:00
parent 87e126ec4d
commit 576bd53487
5 changed files with 224 additions and 93 deletions
+10
View File
@@ -1,4 +1,14 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.3.2',
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
bullets: [
'Official Gelbooru DAPI Alignment: fixed tag suggestions pattern to use SQL LIKE wildcard (%) instead of literal asterisk (*), restoring accurate tag autocompletion.',
'Popularity Tag Sorting: added orderby=count and order=DESC to Gelbooru tag search so the most relevant and widely used tags appear first.',
'Batch Tag Categorization: migrated post tag metadata queries to the official "names" parameter, resolving categories for up to 80 tags in a single batch request instead of dozens of sequential requests.',
'Throttling & Auth Guidance: enhanced API credentials helper text and informative error messages with direct instructions to configure API Key & User ID from Gelbooru account options.',
],
),
LunarisChange( LunarisChange(
version: '2.3.1', version: '2.3.1',
title: 'Cloud Mirrors & Author Announcements Fixes', title: 'Cloud Mirrors & Author Announcements Fixes',
+82 -8
View File
@@ -65,6 +65,7 @@ class GelbooruProvider
if (rating != null && rating.isNotEmpty) 'rating:$rating', if (rating != null && rating.isNotEmpty) 'rating:$rating',
..._topTags(topPeriod), ..._topTags(topPeriod),
]; ];
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -78,15 +79,20 @@ class GelbooruProvider
..._queryParameters, ..._queryParameters,
}, },
); );
_checkResponse(response);
return GelbooruMapper.postsFromResponse( return GelbooruMapper.postsFromResponse(
response.data, response.data,
providerId: id, providerId: id,
providerName: name, providerName: name,
); );
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
Future<Post?> getPost(String id) async { Future<Post?> getPost(String id) async {
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -98,12 +104,16 @@ class GelbooruProvider
..._queryParameters, ..._queryParameters,
}, },
); );
_checkResponse(response);
final posts = GelbooruMapper.postsFromResponse( final posts = GelbooruMapper.postsFromResponse(
response.data, response.data,
providerId: this.id, providerId: this.id,
providerName: name, providerName: name,
); );
return posts.isEmpty ? null : posts.first; return posts.isEmpty ? null : posts.first;
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
@@ -142,6 +152,7 @@ class GelbooruProvider
@override @override
Future<List<PostComment>> getComments(String postId) async { Future<List<PostComment>> getComments(String postId) async {
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -154,6 +165,9 @@ class GelbooruProvider
}, },
); );
return _commentsFromResponse(response.data, postId); return _commentsFromResponse(response.data, postId);
} catch (_) {
return const [];
}
} }
@override @override
@@ -161,6 +175,7 @@ class GelbooruProvider
{int limit = 20}) async { {int limit = 20}) async {
final trimmed = query.trim(); final trimmed = query.trim();
if (trimmed.isEmpty) return const []; if (trimmed.isEmpty) return const [];
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -168,7 +183,9 @@ class GelbooruProvider
's': 'tag', 's': 'tag',
'q': 'index', 'q': 'index',
'json': '1', 'json': '1',
'name_pattern': '$trimmed*', 'name_pattern': '$trimmed%',
'orderby': 'count',
'order': 'DESC',
'limit': limit.clamp(1, 100), 'limit': limit.clamp(1, 100),
..._queryParameters, ..._queryParameters,
}, },
@@ -188,6 +205,9 @@ class GelbooruProvider
}) })
.where((tag) => tag.name.isNotEmpty) .where((tag) => tag.name.isNotEmpty)
.toList(growable: false); .toList(growable: false);
} catch (_) {
return const [];
}
} }
@override @override
@@ -201,11 +221,43 @@ class GelbooruProvider
if (cleaned.isEmpty) return const {}; if (cleaned.isEmpty) return const {};
final groups = <String, List<String>>{}; final groups = <String, List<String>>{};
for (final tag in cleaned) { final tagToCategory = <String, TagCategory>{};
// Batch query tag metadata using Gelbooru DAPI "names" parameter
try { try {
final suggestions = await suggestTags(tag, limit: 3); for (var i = 0; i < cleaned.length; i += 50) {
final exact = suggestions.where((item) => item.name == tag).firstOrNull; final chunk = cleaned.sublist(
final category = exact?.category ?? TagCategory.general; i,
(i + 50 > cleaned.length) ? cleaned.length : i + 50,
);
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'tag',
'q': 'index',
'json': '1',
'names': chunk.join(' '),
'limit': chunk.length.clamp(1, 100),
..._queryParameters,
},
);
for (final item in _tagItems(response.data).whereType<Map>()) {
final json = Map<String, dynamic>.from(item);
final name = (json['name'] ?? '').toString().trim();
if (name.isNotEmpty) {
tagToCategory[name] = tagCategoryFromString(
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
);
}
}
}
} catch (_) {
// Gracefully continue to fallback categorization
}
for (final tag in cleaned) {
final category = tagToCategory[tag] ?? TagCategory.general;
final key = switch (category) { final key = switch (category) {
TagCategory.artist => 'artist', TagCategory.artist => 'artist',
TagCategory.copyright => 'copyright', TagCategory.copyright => 'copyright',
@@ -215,13 +267,35 @@ class GelbooruProvider
TagCategory.general || TagCategory.unknown => 'general', TagCategory.general || TagCategory.unknown => 'general',
}; };
groups.putIfAbsent(key, () => <String>[]).add(tag); groups.putIfAbsent(key, () => <String>[]).add(tag);
} catch (_) {
groups.putIfAbsent('general', () => <String>[]).add(tag);
}
} }
return groups; return groups;
} }
void _checkResponse(Response<dynamic> response) {
if (response.data is String) {
final text = (response.data as String).toLowerCase();
if (text.contains('throttled') ||
text.contains('authentication required') ||
text.contains('requires authentication')) {
unavailable(
'$name requires authentication or is throttled. '
'Please configure API Key & User ID in Settings -> Providers.',
);
}
}
}
Never _handleDioError(DioException error) {
final status = error.response?.statusCode;
if (status == 401 || status == 403 || status == 429) {
unavailable(
'$name requires authentication or rate limit exceeded ($status). '
'Please enter your API Key and User ID in Settings -> Providers.',
);
}
throw error;
}
Never unavailable(String message) => Never unavailable(String message) =>
throw ProviderUnavailableException(message); throw ProviderUnavailableException(message);
@@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _apiKey, controller: _apiKey,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'API key', labelText: 'API key',
helperText: 'Gelbooru: api_key, Danbooru: api_key', helperText:
'Gelbooru/Danbooru API key (Gelbooru: Account -> Options)',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _userId, controller: _userId,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'User ID', labelText: 'User ID',
helperText: 'Gelbooru-compatible APIs may require user_id', helperText:
'Gelbooru User ID (required when throttled, see profile)',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.3.1+41 version: 2.3.2+42
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/gelbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/core/http/dio_client.dart';
void main() {
test('ProviderFactory creates GelbooruProvider with query credentials', () {
final factory = ProviderFactory();
final config = ContentProviderConfig(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
apiType: 'gelbooru',
enabled: true,
priority: 10,
timeoutSeconds: 20,
customHeaders: const {
'query.api_key': 'test_api_key',
'query.user_id': '123456',
},
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final provider = factory.create(config);
expect(provider, isA<GelbooruProvider>());
final gelbooru = provider as GelbooruProvider;
expect(gelbooru.id, 'gelbooru');
expect(gelbooru.queryParameters['api_key'], 'test_api_key');
expect(gelbooru.queryParameters['user_id'], '123456');
});
test('GelbooruProvider mediaHeaders contains Referer and User-Agent', () {
final client = DioClient(baseUrl: 'https://gelbooru.com');
final provider = GelbooruProvider(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
dioClient: client,
);
expect(provider.baseUrl, 'https://gelbooru.com');
});
}