import 'package:dio/dio.dart'; import '../../core/http/dio_client.dart'; import '../models/artist_announcement.dart'; import '../models/artist_link.dart'; import '../models/artist_profile.dart'; import '../models/artist_tag.dart'; import '../models/artist_work_query.dart'; import '../models/post.dart'; import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; import '../services/cloud_link_extractor.dart'; import 'content_provider.dart'; class KemonoProvider implements ContentProvider, ArtistProvider, CommentProvider { KemonoProvider({ required this.id, required this.name, required this.baseUrl, required DioClient dioClient, Map queryParameters = const {}, }) : _dio = dioClient.dio, _creatorDio = Dio( BaseOptions( baseUrl: queryParameters['creator_api_base'] ?? 'https://kemono-api.mbaharip.com', connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 20), headers: const { 'Accept': 'application/json', 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', }, ), ); @override final String id; @override final String name; @override final String baseUrl; final Dio _dio; final Dio _creatorDio; bool get _isCoomer => id.toLowerCase().contains('coomer') || baseUrl.toLowerCase().contains('coomer'); @override Future> searchPosts({ required List tags, required int page, int limit = 50, String? rating, TopPeriodFilter topPeriod = TopPeriodFilter.none, }) async { return const []; } @override Future getPost(String id) async => null; @override Future checkHealth() async { final startedAt = DateTime.now(); try { await _creatorDio.get( _creatorPath(), queryParameters: {'page': 1, 'itemsPerPage': 1}, ); return ProviderHealth( providerId: id, status: ProviderStatus.online, pingMs: DateTime.now().difference(startedAt).inMilliseconds, lastCheckedAt: DateTime.now(), apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists', ); } catch (error) { return ProviderHealth( providerId: id, status: ProviderStatus.offline, pingMs: DateTime.now().difference(startedAt).inMilliseconds, lastCheckedAt: DateTime.now(), errorMessage: error.toString(), apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists', ); } } @override Future> listArtists({ String? service, List? services, String? query, int page = 1, int limit = 30, }) async { final effectiveService = services != null && services.length == 1 ? services.first : service; List list; try { final response = await _creatorDio.get( effectiveService == null || effectiveService.isEmpty ? _creatorPath() : '${_creatorPath()}/$effectiveService', queryParameters: { 'page': page, 'itemsPerPage': limit.clamp(1, 100), if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(), }, ); list = _rankArtists(_artistsFromResponse(response.data), query ?? ''); } catch (_) { list = await _listArtistsFromPublicApi( service: effectiveService, query: query, page: page, limit: limit, ); } if (services != null && services.isNotEmpty) { final sSet = services.map((s) => s.toLowerCase()).toSet(); list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList(); } return list; } @override Future> searchArtists( String query, { String? service, List? services, int page = 1, int limit = 30, }) { return listArtists( service: service, services: services, query: query, page: page, limit: limit, ); } @override Future> getArtistPosts({ required ArtistWorkQuery query, int page = 0, int limit = 50, }) async { Object? lastError; final paths = [ '/api/v1/${query.service}/user/${query.artistId}/posts', '/api/v1/${query.service}/user/${query.artistId}', ]; for (final apiBase in _apiBaseCandidates()) { for (final path in paths) { try { final response = await _getFromApiBase( apiBase, path, queryParameters: {'o': page * limit}, ); final posts = _postItems(response.data); final mapped = []; for (final post in posts.whereType()) { mapped.addAll(_postsFromArtistPost( Map.from(post), query: query, mediaBaseUrl: apiBase, )); } if (mapped.isNotEmpty || posts.isEmpty) { mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt)); return mapped.take(limit).toList(growable: false); } } catch (error) { lastError = error; } } } throw StateError( '$name artist works are unavailable right now. Try again later. ' '${_shortError(lastError)}', ); } @override Future> getArtistPostComments( String service, String artistId, String postId, ) async { Object? lastError; final path = '/api/v1/$service/user/$artistId/post/$postId/comments'; for (final apiBase in _apiBaseCandidates()) { try { final response = await _getFromApiBase(apiBase, path); return _commentsFromResponse(response.data, postId); } catch (error) { lastError = error; } } throw StateError( '$name comments are unavailable right now. ${_shortError(lastError)}', ); } @override Future> getComments(String postId) async { final parts = postId.split(':'); if (parts.length < 4) return const []; return getArtistPostComments(parts[0], parts[1], parts[2]); } @override Future> getArtistTags( String service, String artistId) async => const []; @override Future> getArtistLinks( String service, String artistId) async => const []; @override Future> getArtistAnnouncements( String service, String artistId) async => const []; String _creatorPath() => _isCoomer ? '/coomer' : '/kemono'; List _artistsFromResponse(dynamic data) { final items = data is Map ? data['data'] : data; if (items is! List) return const []; return items .whereType() .map((item) { final json = Map.from(item); final service = (json['service'] ?? '').toString(); final artistId = (json['id'] ?? json['creator_id'] ?? json['user_id'] ?? '') .toString(); final name = (json['name'] ?? json['display_name'] ?? json['username'] ?? artistId) .toString(); final rawAvatar = (json['avatar'] ?? json['icon'] ?? json['image'] ?? json['avatar_url'] ?? '') .toString(); return ArtistProfile( id: artistId, providerId: id, service: service, name: name, displayName: name, avatarUrl: _absoluteUrl(rawAvatar) ?? _iconUrl(service, artistId), updatedAt: _dateFromAny(json['updated']), postCount: (json['post_count'] as num?)?.toInt(), url: '$baseUrl/$service/user/$artistId', ); }) .where((artist) => artist.id.isNotEmpty) .toList(growable: false); } Future> _listArtistsFromPublicApi({ String? service, String? query, required int page, required int limit, }) async { Object? lastError; dynamic data; for (final apiBase in _apiBaseCandidates()) { try { final response = await _getFromApiBase(apiBase, '/api/v1/creators.txt'); data = response.data; break; } catch (error) { lastError = error; } } if (data == null) { throw StateError( '$name artist index is unavailable right now. ${_shortError(lastError)}', ); } final queryText = (query ?? '').trim().toLowerCase(); final offset = (page - 1).clamp(0, 999999) * limit; final items = _artistsFromResponse({'data': data}) .where((artist) => (service == null || service.isEmpty || artist.service == service) && (queryText.isEmpty || artist.displayName.toLowerCase().contains(queryText) || artist.id.toLowerCase().contains(queryText))) .toList(growable: false); final ranked = _rankArtists(items, queryText); return ranked.skip(offset).take(limit).toList(growable: false); } List _rankArtists(List artists, String query) { final queryText = query.trim().toLowerCase(); final ranked = [...artists]; ranked.sort((a, b) { final scoreA = _artistSearchScore(a, queryText); final scoreB = _artistSearchScore(b, queryText); if (scoreA != scoreB) return scoreB.compareTo(scoreA); final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0); final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0); final updated = updatedB.compareTo(updatedA); if (updated != 0) return updated; final count = (b.postCount ?? 0).compareTo(a.postCount ?? 0); if (count != 0) return count; return a.displayName.compareTo(b.displayName); }); return ranked; } int _artistSearchScore(ArtistProfile artist, String query) { if (query.isEmpty) return 0; final name = artist.displayName.toLowerCase(); final id = artist.id.toLowerCase(); if (name == query || id == query) return 100; if (name.startsWith(query) || id.startsWith(query)) return 70; if (name.contains(query) || id.contains(query)) return 40; return 0; } List _postItems(dynamic data) { if (data is List) return data; if (data is Map) { final results = data['results']; if (results is List) return results; final posts = data['posts']; if (posts is List) return posts; if (posts is Map) return _postItems(posts); final dataItems = data['data']; if (dataItems is List) return dataItems; } return const []; } List _postsFromArtistPost( Map json, { required ArtistWorkQuery query, required String mediaBaseUrl, }) { final postId = (json['id'] ?? json['post_id'] ?? '').toString(); final title = (json['title'] ?? '').toString(); final published = _dateFromAny(json['published']) ?? _dateFromAny(json['added']) ?? _dateFromAny(json['edited']) ?? DateTime.fromMillisecondsSinceEpoch(0); final postSource = '$baseUrl/${query.service}/user/${query.artistId}/post/$postId'; final rawContent = (json['content'] ?? '').toString(); final embed = json['embed'] is Map ? Map.from(json['embed']) : null; final cloudLinks = CloudLinkExtractor.extractLinks( content: rawContent, source: postSource, embed: embed, ); final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false); final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent); final files = >[]; final file = json['file']; if (file is Map && file.isNotEmpty) { files.add(Map.from(file)); } final attachments = json['attachments']; if (attachments is List) { files.addAll(attachments.whereType().map(Map.from)); } // Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.) if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) { final stableId = '${query.service}:${query.artistId}:$postId:0'; final firstStreamable = cloudLinks.where((l) => l.isStreamable).firstOrNull; final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null; final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? ''; final tags = [ query.service, query.artistName, if (title.trim().isNotEmpty) title.trim(), 'cloud_mirror', ]; return [ Post( id: stableId, providerId: id, providerName: name, previewUrl: '', sampleUrl: '', fileUrl: fileUrl, tags: tags, rating: 'unknown', width: 0, height: 0, source: postSource, createdAt: published, fileType: firstStreamable != null ? 'video' : 'link', score: 0, tagGroups: { 'artist': [query.artistName], 'meta': [query.service], if (title.trim().isNotEmpty) 'copyright': [title.trim()], if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson, if (cleanDesc.isNotEmpty) 'description': [cleanDesc], }, ), ]; } var index = 0; return files .map((file) { final url = _mediaUrl(file, mediaBaseUrl: mediaBaseUrl); final fileName = (file['name'] ?? Uri.tryParse(url)?.pathSegments.last ?? '') .toString(); final type = _fileType('$fileName $url'); final stableId = '${query.service}:${query.artistId}:$postId:${index++}'; final tags = [ query.service, query.artistName, if (title.trim().isNotEmpty) title.trim(), ]; return Post( id: stableId, providerId: id, providerName: name, previewUrl: url, sampleUrl: url, fileUrl: url, tags: tags, rating: 'unknown', width: 0, height: 0, source: postSource, createdAt: published, fileType: type, score: 0, tagGroups: { 'artist': [query.artistName], 'meta': [query.service], if (title.trim().isNotEmpty) 'copyright': [title.trim()], if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson, if (cleanDesc.isNotEmpty) 'description': [cleanDesc], }, ); }) .where((post) => post.fileUrl.isNotEmpty) .toList(growable: false); } List _commentsFromResponse(dynamic data, String postId) { final items = data is Map ? (data['comments'] ?? data['data']) : data; if (items is! List) return const []; return items .whereType() .map((item) { final json = Map.from(item); return PostComment( id: (json['id'] ?? json['comment_id'] ?? '').toString(), postId: postId, providerId: id, authorName: (json['author'] ?? json['username'] ?? json['user'] ?? json['creator_name'] ?? 'user') .toString(), body: (json['content'] ?? json['body'] ?? json['comment'] ?? '') .toString(), createdAt: _dateFromAny(json['added']) ?? _dateFromAny(json['created_at']) ?? DateTime.fromMillisecondsSinceEpoch(0), ); }) .where((comment) => comment.body.trim().isNotEmpty) .toList(growable: false); } String _mediaUrl( Map file, { required String mediaBaseUrl, }) { final path = (file['path'] ?? file['url'] ?? '').toString(); if (path.isEmpty) return ''; final uri = Uri.tryParse(path); if (uri != null && uri.hasScheme) return path; if (path.startsWith('/data/')) { return '$mediaBaseUrl$path'; } if (path.startsWith('/')) { return '$mediaBaseUrl/data$path'; } return '$mediaBaseUrl/data/$path'; } String? _iconUrl(String service, String artistId) { if (service.isEmpty || artistId.isEmpty) return null; final host = _isCoomer ? 'https://img.coomer.su' : 'https://img.kemono.su'; return '$host/icons/$service/$artistId'; } String? _absoluteUrl(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) return null; final uri = Uri.tryParse(trimmed); if (uri != null && uri.hasScheme) return trimmed; if (trimmed.startsWith('/')) return '$baseUrl$trimmed'; return '$baseUrl/$trimmed'; } List _apiBaseCandidates() { final candidates = [ baseUrl, if (_isCoomer) ...[ 'https://coomer.st', 'https://coomer.su', ] else ...[ 'https://kemono.cr', 'https://kemono.su', ], ]; return candidates.map(_trimTrailingSlash).toSet().toList(growable: false); } Future> _getFromApiBase( String apiBase, String path, { Map queryParameters = const {}, }) { if (_trimTrailingSlash(apiBase) == _trimTrailingSlash(baseUrl)) { return _dio.get(path, queryParameters: queryParameters); } final dio = Dio( BaseOptions( baseUrl: apiBase, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 25), headers: const { 'Accept': 'application/json', 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', }, ), ); return dio.get(path, queryParameters: queryParameters); } String _trimTrailingSlash(String value) => value.endsWith('/') ? value.substring(0, value.length - 1) : value; String _shortError(Object? error) { if (error == null) return ''; final message = error.toString(); if (message.length <= 160) return message; return '${message.substring(0, 160)}...'; } String _fileType(String value) { final lower = value.toLowerCase().split('?').first; if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video'; if (lower.endsWith('.gif')) return 'gif'; if (lower.endsWith('.swf')) return 'swf'; if (lower.endsWith('.png')) return 'png'; if (lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.webp')) { return 'photo'; } return 'unknown'; } DateTime? _dateFromAny(dynamic value) { if (value == null) return null; if (value is num) { final raw = value.toInt(); if (raw > 9999999999) return DateTime.fromMillisecondsSinceEpoch(raw); return DateTime.fromMillisecondsSinceEpoch(raw * 1000); } return DateTime.tryParse(value.toString()); } }