feat: complete e621 integration with API key auth, comics/pools, post variations, and server sync (v3.7.0)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled

This commit is contained in:
2026-09-05 19:48:52 +03:00
parent 54b77726cb
commit cc6ad2d7bf
16 changed files with 1711 additions and 8 deletions
+32
View File
@@ -36,6 +36,38 @@ class E621Mapper {
for (final entry in tagsMap.entries)
entry.key: List<String>.from((entry.value as List?) ?? const []),
}..removeWhere((_, value) => value.isEmpty);
final relationships = Map<String, dynamic>.from((json['relationships'] as Map?) ?? const {});
final parentId = relationships['parent_id']?.toString();
if (parentId != null && parentId.isNotEmpty && parentId != 'null') {
tagGroups['parent_id'] = [parentId];
}
final children = (relationships['children'] as List?)
?.map((e) => e?.toString() ?? '')
.where((s) => s.isNotEmpty && s != 'null')
.toList();
if (children != null && children.isNotEmpty) {
tagGroups['children'] = children;
}
final rawPools = (json['pools'] as List?)
?.map((e) => e?.toString() ?? '')
.where((s) => s.isNotEmpty)
.toList();
if (rawPools != null && rawPools.isNotEmpty) {
tagGroups['pools'] = rawPools;
}
final favCount = json['fav_count'];
if (favCount != null) {
tagGroups['fav_count'] = [favCount.toString()];
}
final desc = _string(json['description']);
if (desc.isNotEmpty) {
tagGroups['description'] = [desc];
}
final tags = tagGroups.values.expand((items) => items).toSet().toList();
final fileUrl = _string(file['url']);
return Post(
+58
View File
@@ -0,0 +1,58 @@
class E621Pool {
const E621Pool({
required this.id,
required this.name,
required this.description,
required this.postIds,
required this.postCount,
required this.category,
required this.isActive,
this.creatorName,
});
final int id;
final String name;
final String description;
final List<String> postIds;
final int postCount;
final String category; // 'series' or 'collection'
final bool isActive;
final String? creatorName;
String get cleanTitle => name.replaceAll('_', ' ');
int pageOf(String postId) {
final idx = postIds.indexOf(postId);
return idx >= 0 ? idx + 1 : 0;
}
String? nextPostId(String postId) {
final idx = postIds.indexOf(postId);
if (idx >= 0 && idx < postIds.length - 1) {
return postIds[idx + 1];
}
return null;
}
String? previousPostId(String postId) {
final idx = postIds.indexOf(postId);
if (idx > 0) {
return postIds[idx - 1];
}
return null;
}
factory E621Pool.fromJson(Map<String, dynamic> json) {
final rawPostIds = (json['post_ids'] as List?) ?? const [];
return E621Pool(
id: (json['id'] as num?)?.toInt() ?? 0,
name: (json['name'] ?? '').toString(),
description: (json['description'] ?? '').toString(),
postIds: rawPostIds.map((e) => e.toString()).toList(),
postCount: (json['post_count'] as num?)?.toInt() ?? rawPostIds.length,
category: (json['category'] ?? 'series').toString(),
isActive: (json['is_active'] as bool?) ?? true,
creatorName: json['creator_name']?.toString(),
);
}
}
+21
View File
@@ -62,6 +62,27 @@ class Post {
String? get description => commentary;
String? get parentId {
final list = tagGroups['parent_id'];
return (list != null && list.isNotEmpty && list.first.trim().isNotEmpty)
? list.first.trim()
: null;
}
List<String> get childrenIds => tagGroups['children'] ?? const [];
List<String> get poolIds => tagGroups['pools'] ?? const [];
bool get hasRelations =>
(parentId != null && parentId!.isNotEmpty) || childrenIds.isNotEmpty;
bool get hasPools => poolIds.isNotEmpty;
int? get favCount {
final raw = tagGroups['fav_count']?.firstOrNull;
return raw != null ? int.tryParse(raw) : null;
}
String? get title {
final list = tagGroups['copyright'];
if (list != null && list.isNotEmpty) {
+80
View File
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../mappers/e621_mapper.dart';
import '../models/e621_pool.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
@@ -33,6 +34,24 @@ class E621Provider
final Dio _dio;
final Map<String, String> _queryParameters;
static DateTime _lastRequestTime = DateTime.fromMillisecondsSinceEpoch(0);
static Future<void> _throttle() async {
final now = DateTime.now();
final diff = now.difference(_lastRequestTime);
if (diff.inMilliseconds < 500) {
final waitMs = 500 - diff.inMilliseconds;
await Future<void>.delayed(Duration(milliseconds: waitMs));
}
_lastRequestTime = DateTime.now();
}
bool get isAuthorized =>
_queryParameters.containsKey('login') ||
_dio.options.headers.containsKey('Authorization');
String? get login => _queryParameters['login'];
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@@ -44,6 +63,7 @@ class E621Provider
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/posts.json',
queryParameters: {
@@ -66,6 +86,7 @@ class E621Provider
@override
Future<Post?> getPost(String id) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/posts/$id.json',
queryParameters: _queryParameters,
@@ -78,6 +99,63 @@ class E621Provider
return posts.isEmpty ? null : posts.first;
}
Future<E621Pool?> getPool(String poolId) async {
await _throttle();
try {
final response = await _dio.get<dynamic>(
'/pools/$poolId.json',
queryParameters: _queryParameters,
);
if (response.data is Map) {
return E621Pool.fromJson(Map<String, dynamic>.from(response.data as Map));
}
} catch (_) {}
return null;
}
Future<bool> votePost(String postId, int score) async {
await _throttle();
try {
final response = await _dio.post<dynamic>(
'/posts/$postId/votes.json',
data: {'score': score, 'no_unvote': 'true'},
queryParameters: _queryParameters,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
return response.statusCode == 200 || response.statusCode == 201;
} catch (_) {
return false;
}
}
Future<bool> addFavorite(String postId) async {
await _throttle();
try {
final response = await _dio.post<dynamic>(
'/favorites.json',
data: {'post_id': postId},
queryParameters: _queryParameters,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
return response.statusCode == 200 || response.statusCode == 201;
} catch (_) {
return false;
}
}
Future<bool> removeFavorite(String postId) async {
await _throttle();
try {
final response = await _dio.delete<dynamic>(
'/favorites/$postId.json',
queryParameters: _queryParameters,
);
return response.statusCode == 200 || response.statusCode == 204;
} catch (_) {
return false;
}
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
@@ -109,6 +187,7 @@ class E621Provider
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
if (query.trim().isEmpty) return const [];
await _throttle();
final response = await _dio.get<dynamic>(
'/tags.json',
queryParameters: {
@@ -136,6 +215,7 @@ class E621Provider
@override
Future<List<PostComment>> getComments(String postId) async {
await _throttle();
final response = await _dio.get<dynamic>(
'/comments.json',
queryParameters: {
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../core/http/dio_client.dart';
import '../models/content_provider_config.dart';
import 'content_provider.dart';
@@ -20,6 +22,17 @@ class ProviderFactory {
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
entry.key.substring('query.'.length): entry.value.trim(),
};
if (config.apiType.toLowerCase() == 'e621') {
final login = queryParameters['login'] ?? headers['login'];
final apiKey = queryParameters['api_key'] ?? headers['api_key'];
if (login != null && apiKey != null && login.isNotEmpty && apiKey.isNotEmpty) {
final basicAuth = base64Encode(utf8.encode('$login:$apiKey'));
headers['Authorization'] = 'Basic $basicAuth';
headers['User-Agent'] = 'Prisma/3.6.6 (by $login on e621)';
}
}
final client = DioClient(
baseUrl: config.baseUrl,
timeout: Duration(seconds: config.timeoutSeconds),
@@ -144,6 +144,14 @@ class ProviderManager {
});
}
Future<ContentProvider?> getProviderInstance(String id) async {
final result = await _repository.getProvider(id);
if (result is Success<ContentProviderConfig?> && result.data != null) {
return _factory.create(result.data!);
}
return null;
}
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {