feat: align with Gelbooru DAPI spec, optimize tag queries & batch categorization (v2.3.2)
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user