diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 38e642b..74f6cf0 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ 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( version: '2.3.1', title: 'Cloud Mirrors & Author Announcements Fixes', diff --git a/lib/backend/providers/gelbooru_provider.dart b/lib/backend/providers/gelbooru_provider.dart index cf322c1..1ba7ee3 100644 --- a/lib/backend/providers/gelbooru_provider.dart +++ b/lib/backend/providers/gelbooru_provider.dart @@ -65,45 +65,55 @@ class GelbooruProvider if (rating != null && rating.isNotEmpty) 'rating:$rating', ..._topTags(topPeriod), ]; - final response = await _dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'post', - 'q': 'index', - 'json': '1', - 'pid': page, - 'limit': limit, - 'tags': queryTags.join(' '), - ..._queryParameters, - }, - ); - return GelbooruMapper.postsFromResponse( - response.data, - providerId: id, - providerName: name, - ); + try { + final response = await _dio.get( + '/index.php', + queryParameters: { + 'page': 'dapi', + 's': 'post', + 'q': 'index', + 'json': '1', + 'pid': page, + 'limit': limit, + 'tags': queryTags.join(' '), + ..._queryParameters, + }, + ); + _checkResponse(response); + return GelbooruMapper.postsFromResponse( + response.data, + providerId: id, + providerName: name, + ); + } on DioException catch (e) { + _handleDioError(e); + } } @override Future getPost(String id) async { - final response = await _dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'post', - 'q': 'index', - 'json': '1', - 'id': id, - ..._queryParameters, - }, - ); - final posts = GelbooruMapper.postsFromResponse( - response.data, - providerId: this.id, - providerName: name, - ); - return posts.isEmpty ? null : posts.first; + try { + final response = await _dio.get( + '/index.php', + queryParameters: { + 'page': 'dapi', + 's': 'post', + 'q': 'index', + 'json': '1', + 'id': id, + ..._queryParameters, + }, + ); + _checkResponse(response); + final posts = GelbooruMapper.postsFromResponse( + response.data, + providerId: this.id, + providerName: name, + ); + return posts.isEmpty ? null : posts.first; + } on DioException catch (e) { + _handleDioError(e); + } } @override @@ -142,18 +152,22 @@ class GelbooruProvider @override Future> getComments(String postId) async { - final response = await _dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'comment', - 'q': 'index', - 'json': '1', - 'post_id': postId, - ..._queryParameters, - }, - ); - return _commentsFromResponse(response.data, postId); + try { + final response = await _dio.get( + '/index.php', + queryParameters: { + 'page': 'dapi', + 's': 'comment', + 'q': 'index', + 'json': '1', + 'post_id': postId, + ..._queryParameters, + }, + ); + return _commentsFromResponse(response.data, postId); + } catch (_) { + return const []; + } } @override @@ -161,33 +175,39 @@ class GelbooruProvider {int limit = 20}) async { final trimmed = query.trim(); if (trimmed.isEmpty) return const []; - final response = await _dio.get( - '/index.php', - queryParameters: { - 'page': 'dapi', - 's': 'tag', - 'q': 'index', - 'json': '1', - 'name_pattern': '$trimmed*', - 'limit': limit.clamp(1, 100), - ..._queryParameters, - }, - ); - return _tagItems(response.data) - .whereType() - .map((item) { - final json = Map.from(item); - return TagSuggestion( - name: (json['name'] ?? '').toString(), - category: tagCategoryFromString( - (json['type'] ?? json['tag_type'] ?? json['category']).toString(), - ), - postCount: _int(json['count'] ?? json['post_count']), - providerId: id, - ); - }) - .where((tag) => tag.name.isNotEmpty) - .toList(growable: false); + try { + final response = await _dio.get( + '/index.php', + queryParameters: { + 'page': 'dapi', + 's': 'tag', + 'q': 'index', + 'json': '1', + 'name_pattern': '$trimmed%', + 'orderby': 'count', + 'order': 'DESC', + 'limit': limit.clamp(1, 100), + ..._queryParameters, + }, + ); + return _tagItems(response.data) + .whereType() + .map((item) { + final json = Map.from(item); + return TagSuggestion( + name: (json['name'] ?? '').toString(), + category: tagCategoryFromString( + (json['type'] ?? json['tag_type'] ?? json['category']).toString(), + ), + postCount: _int(json['count'] ?? json['post_count']), + providerId: id, + ); + }) + .where((tag) => tag.name.isNotEmpty) + .toList(growable: false); + } catch (_) { + return const []; + } } @override @@ -201,27 +221,81 @@ class GelbooruProvider if (cleaned.isEmpty) return const {}; final groups = >{}; - for (final tag in cleaned) { - try { - final suggestions = await suggestTags(tag, limit: 3); - final exact = suggestions.where((item) => item.name == tag).firstOrNull; - final category = exact?.category ?? TagCategory.general; - final key = switch (category) { - TagCategory.artist => 'artist', - TagCategory.copyright => 'copyright', - TagCategory.character => 'character', - TagCategory.meta => 'meta', - TagCategory.species => 'species', - TagCategory.general || TagCategory.unknown => 'general', - }; - groups.putIfAbsent(key, () => []).add(tag); - } catch (_) { - groups.putIfAbsent('general', () => []).add(tag); + final tagToCategory = {}; + + // Batch query tag metadata using Gelbooru DAPI "names" parameter + try { + for (var i = 0; i < cleaned.length; i += 50) { + final chunk = cleaned.sublist( + i, + (i + 50 > cleaned.length) ? cleaned.length : i + 50, + ); + final response = await _dio.get( + '/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()) { + final json = Map.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) { + TagCategory.artist => 'artist', + TagCategory.copyright => 'copyright', + TagCategory.character => 'character', + TagCategory.meta => 'meta', + TagCategory.species => 'species', + TagCategory.general || TagCategory.unknown => 'general', + }; + groups.putIfAbsent(key, () => []).add(tag); } return groups; } + void _checkResponse(Response 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) => throw ProviderUnavailableException(message); diff --git a/lib/features/providers/presentation/provider_form_screen.dart b/lib/features/providers/presentation/provider_form_screen.dart index 85492f2..a7c0210 100644 --- a/lib/features/providers/presentation/provider_form_screen.dart +++ b/lib/features/providers/presentation/provider_form_screen.dart @@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState { controller: _apiKey, decoration: const InputDecoration( labelText: 'API key', - helperText: 'Gelbooru: api_key, Danbooru: api_key', + helperText: + 'Gelbooru/Danbooru API key (Gelbooru: Account -> Options)', ), ), const SizedBox(height: 12), @@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState { controller: _userId, decoration: const InputDecoration( labelText: 'User ID', - helperText: 'Gelbooru-compatible APIs may require user_id', + helperText: + 'Gelbooru User ID (required when throttled, see profile)', ), ), const SizedBox(height: 12), diff --git a/pubspec.yaml b/pubspec.yaml index 721f26a..11dadc8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.3.1+41 +version: 2.3.2+42 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/gelbooru_dapi_test.dart b/test/backend/gelbooru_dapi_test.dart new file mode 100644 index 0000000..6d84f81 --- /dev/null +++ b/test/backend/gelbooru_dapi_test.dart @@ -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()); + 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'); + }); +}