From af00cb8179d6aec114f7fd508fcc1c3b6916003d Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 15:35:41 +0300 Subject: [PATCH] fix: resolve video misclassification when filename contains 'audio' and remove legacy Kemono/Coomer providers --- README.md | 2 +- README_RU.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 9 + lib/backend/providers/kemono_provider.dart | 658 ------------------ lib/backend/providers/pawchive_provider.dart | 106 +-- lib/backend/providers/provider_factory.dart | 10 - lib/backend/providers/provider_manager.dart | 11 +- lib/backend/utils/media_quality.dart | 140 ++-- .../presentation/provider_form_screen.dart | 8 - .../presentation/widgets/provider_card.dart | 10 - pubspec.yaml | 2 +- test/backend/pawchive_provider_test.dart | 23 + test/backend/provider_parser_test.dart | 5 +- 14 files changed, 210 insertions(+), 780 deletions(-) delete mode 100644 lib/backend/providers/kemono_provider.dart diff --git a/README.md b/README.md index 16b3ba2..099b584 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- Version 3.5.3 + Version 3.5.4 Flutter Platforms Wiki diff --git a/README_RU.md b/README_RU.md index 7de87ae..064980b 100644 --- a/README_RU.md +++ b/README_RU.md @@ -14,7 +14,7 @@

- Version 3.5.3 + Version 3.5.4 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 09c7d43..da6cdf2 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.5.3'; -const appBuildNumber = 79; +const appDisplayVersion = '3.5.4'; +const appBuildNumber = 80; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index cc69d6c..ee65aff 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,13 @@ const prismaChangelog = [ + PrismaChange( + version: '3.5.4', + title: 'Исправление распознавания MP4-видео и отключение устаревших сервисов Kemono/Coomer', + bullets: [ + 'Устранена ошибка классификации видео: файлы MP4, содержащие слово «audio» в имени файла или параметрах запроса, больше не распознаются как аудио и корректно воспроизводятся как полноценное видео.', + 'Точное определение расширений: внедрён строгий парсинг расширений файлов, исключающий ложные срабатывания при определении типа медиа.', + 'Удаление устаревших провайдеров Kemono и Coomer: окончательно удалены неработающие сервисы Kemono и Coomer из списка провайдеров и настроек (основным источником артистов является Pawchive).', + ], + ), PrismaChange( version: '3.5.3', title: 'Поддержка аудиозаписей (MP3/WAV/FLAC) и исправление загрузки медиа у авторов', diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart deleted file mode 100644 index 5160d9d..0000000 --- a/lib/backend/providers/kemono_provider.dart +++ /dev/null @@ -1,658 +0,0 @@ -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': 'Prisma/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 { - final path = '/api/v1/$service/user/$artistId/announcements'; - for (final apiBase in _apiBaseCandidates()) { - try { - final response = await _getFromApiBase(apiBase, path); - final items = response.data; - if (items is List) { - return items - .whereType() - .map((item) => - ArtistAnnouncement.fromJson(Map.from(item))) - .toList(growable: false); - } - } catch (_) {} - } - return 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.) - // or text-only posts where author wrote an announcement, status or note - if (files.isEmpty && - (cloudLinks.isNotEmpty || - cleanDesc.isNotEmpty || - rawContent.trim().isNotEmpty || - title.trim().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 isTextOnly = cloudLinks.isEmpty; - final tags = [ - query.service, - query.artistName, - if (title.trim().isNotEmpty) title.trim(), - if (!isTextOnly) 'cloud_mirror' else 'text_post', - ]; - 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' - : (isTextOnly ? 'text' : '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] - else if (rawContent.trim().isNotEmpty) - 'description': [rawContent.trim()], - }, - ), - ]; - } - - 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': 'Prisma/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.trim(); - if (lower.endsWith('.webm') || - lower.endsWith('.mp4') || - lower.endsWith('.mov') || - lower.endsWith('.mkv') || - lower.endsWith('.avi') || - lower.endsWith('.flv') || - lower.endsWith('.wmv') || - lower.endsWith('.m4v') || - lower.endsWith('.ts')) { - return 'video'; - } - if (lower.endsWith('.mp3') || - lower.endsWith('.m4a') || - lower.endsWith('.wav') || - lower.endsWith('.ogg') || - lower.endsWith('.flac') || - lower.endsWith('.aac') || - lower.endsWith('.opus') || - lower.endsWith('.wma')) { - return 'audio'; - } - if (lower.endsWith('.gif')) return 'gif'; - if (lower.endsWith('.swf')) return 'swf'; - if (lower.endsWith('.zip') || - lower.endsWith('.rar') || - lower.endsWith('.7z') || - lower.endsWith('.tar') || - lower.endsWith('.gz') || - lower.endsWith('.pdf') || - lower.endsWith('.txt')) { - return 'archive'; - } - if (lower.endsWith('.png') || - lower.endsWith('.jpg') || - lower.endsWith('.jpeg') || - lower.endsWith('.webp') || - lower.endsWith('.avif') || - lower.endsWith('.bmp') || - lower.endsWith('.heic') || - lower.endsWith('.tiff')) { - 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()); - } -} diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart index 0350058..1abf7fe 100644 --- a/lib/backend/providers/pawchive_provider.dart +++ b/lib/backend/providers/pawchive_provider.dart @@ -481,7 +481,7 @@ class PawchiveProvider for (final f in files) { final p = (f['path'] ?? '').toString(); final n = (f['name'] ?? '').toString(); - if (_isPhotoType('$n $p') && p.isNotEmpty) { + if (_isPhotoType(p, n) && p.isNotEmpty) { postCoverUrl = _thumbnailUrl(p); break; } @@ -494,8 +494,8 @@ class PawchiveProvider final fileName = (fileMap['name'] ?? '').toString(); final previewOnly = fileMap['preview_only'] == true; - final type = _fileType('$fileName $rawPath'); - final isPhoto = _isPhotoType('$fileName $rawPath'); + final type = _fileTypeFor(rawPath, fileName); + final isPhoto = type == 'photo' || type == 'gif'; // img.pawchive.pw/thumbnail only generates thumbnails for images/gifs. // Non-image files (audio, video, archive) return 404 from thumbnail server. @@ -626,55 +626,81 @@ class PawchiveProvider return '${message.substring(0, 160)}...'; } + String _extractExtension(String value) { + if (value.isEmpty) return ''; + final clean = value.split('?').first.split('#').first.trim(); + final dot = clean.lastIndexOf('.'); + if (dot != -1 && dot < clean.length - 1) { + final ext = clean.substring(dot).toLowerCase(); + if (ext.length <= 6) return ext; + } + return ''; + } + String _fileType(String value) { - final lower = value.toLowerCase().split('?').first.trim(); - if (lower.endsWith('.webm') || - lower.endsWith('.mp4') || - lower.endsWith('.mov') || - lower.endsWith('.mkv') || - lower.endsWith('.avi') || - lower.endsWith('.flv') || - lower.endsWith('.wmv') || - lower.endsWith('.m4v') || - lower.endsWith('.ts')) { + final ext = _extractExtension(value); + if (const { + '.webm', + '.mp4', + '.mov', + '.mkv', + '.avi', + '.flv', + '.wmv', + '.m4v', + '.ts', + }.contains(ext)) { return 'video'; } - if (lower.endsWith('.mp3') || - lower.endsWith('.m4a') || - lower.endsWith('.wav') || - lower.endsWith('.ogg') || - lower.endsWith('.flac') || - lower.endsWith('.aac') || - lower.endsWith('.opus') || - lower.endsWith('.wma')) { + if (const { + '.mp3', + '.m4a', + '.wav', + '.ogg', + '.flac', + '.aac', + '.opus', + '.wma', + }.contains(ext)) { return 'audio'; } - if (lower.endsWith('.gif')) return 'gif'; - if (lower.endsWith('.swf')) return 'swf'; - if (lower.endsWith('.zip') || - lower.endsWith('.rar') || - lower.endsWith('.7z') || - lower.endsWith('.tar') || - lower.endsWith('.gz') || - lower.endsWith('.pdf') || - lower.endsWith('.txt')) { + if (ext == '.gif') return 'gif'; + if (ext == '.swf') return 'swf'; + if (const { + '.zip', + '.rar', + '.7z', + '.tar', + '.gz', + '.pdf', + '.txt', + }.contains(ext)) { return 'archive'; } - if (lower.endsWith('.png') || - lower.endsWith('.jpg') || - lower.endsWith('.jpeg') || - lower.endsWith('.webp') || - lower.endsWith('.avif') || - lower.endsWith('.bmp') || - lower.endsWith('.heic') || - lower.endsWith('.tiff')) { + if (const { + '.png', + '.jpg', + '.jpeg', + '.webp', + '.avif', + '.bmp', + '.heic', + '.tiff', + }.contains(ext)) { return 'photo'; } return 'unknown'; } - bool _isPhotoType(String value) { - final type = _fileType(value); + String _fileTypeFor(String path, String fileName) { + final extName = _extractExtension(fileName); + final extPath = _extractExtension(path); + final ext = extName.isNotEmpty ? extName : extPath; + return _fileType(ext); + } + + bool _isPhotoType(String path, [String fileName = '']) { + final type = _fileTypeFor(path, fileName); return type == 'photo' || type == 'gif'; } diff --git a/lib/backend/providers/provider_factory.dart b/lib/backend/providers/provider_factory.dart index f89d499..dfb1e5d 100644 --- a/lib/backend/providers/provider_factory.dart +++ b/lib/backend/providers/provider_factory.dart @@ -5,7 +5,6 @@ import 'custom_provider.dart'; import 'danbooru_provider.dart'; import 'e621_provider.dart'; import 'gelbooru_provider.dart'; -import 'kemono_provider.dart'; import 'moebooru_provider.dart'; import 'pawchive_provider.dart'; import 'realbooru_html_provider.dart'; @@ -82,15 +81,6 @@ class ProviderFactory { dioClient: client, queryParameters: queryParameters, ); - case 'kemono': - case 'coomer': - return KemonoProvider( - id: config.id, - name: config.name, - baseUrl: config.baseUrl, - dioClient: client, - queryParameters: queryParameters, - ); case 'pawchive': return PawchiveProvider( id: config.id, diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index 2705e66..fe0cc86 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -730,18 +730,23 @@ class ProviderManager { static bool _isArtistConfig(ContentProviderConfig config) { final type = config.apiType.toLowerCase(); - return type == 'kemono' || type == 'coomer' || type == 'pawchive'; + return type == 'pawchive'; } static bool _isFeedConfig(ContentProviderConfig config) { final type = config.apiType.toLowerCase(); - return type != 'kemono' && type != 'coomer'; + return type != 'pawchive'; } static bool _isLegacyRemovedConfig(ContentProviderConfig config) { final id = config.id.toLowerCase(); final type = config.apiType.toLowerCase(); - return id == 'cosbooru' || type == 'realbooru'; + return id == 'cosbooru' || + type == 'realbooru' || + type == 'kemono' || + type == 'coomer' || + id == 'kemono' || + id == 'coomer'; } } diff --git a/lib/backend/utils/media_quality.dart b/lib/backend/utils/media_quality.dart index e8dc801..e602fc9 100644 --- a/lib/backend/utils/media_quality.dart +++ b/lib/backend/utils/media_quality.dart @@ -100,61 +100,115 @@ class MediaUrlSelector { .toList(growable: false); } + static const _videoExtensions = { + '.mp4', + '.webm', + '.mov', + '.mkv', + '.avi', + '.flv', + '.wmv', + '.m4v', + '.ts', + }; + + static const _audioExtensions = { + '.mp3', + '.m4a', + '.wav', + '.ogg', + '.flac', + '.aac', + '.opus', + '.wma', + }; + + static String _extractExtension(String url) { + if (url.isEmpty) return ''; + try { + final uri = Uri.parse(url); + final filenameParam = + uri.queryParameters['f'] ?? uri.queryParameters['filename']; + if (filenameParam != null && filenameParam.isNotEmpty) { + final cleanParam = filenameParam.split('?').first.split('#').first; + final dot = cleanParam.lastIndexOf('.'); + if (dot != -1 && dot < cleanParam.length - 1) { + final ext = cleanParam.substring(dot).toLowerCase(); + if (ext.length <= 6) return ext; + } + } + final path = uri.path; + final dot = path.lastIndexOf('.'); + if (dot != -1 && dot < path.length - 1) { + final ext = path.substring(dot).toLowerCase(); + if (ext.length <= 6) return ext; + } + } catch (_) { + // Uri parsing failed, fallback below + } + + final clean = url.split('?').first.split('#').first; + final dot = clean.lastIndexOf('.'); + if (dot != -1 && dot < clean.length - 1) { + final ext = clean.substring(dot).toLowerCase(); + if (ext.length <= 6) return ext; + } + return ''; + } + static bool _isAudio(Post post) { - final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); - return value.contains('audio') || - value.contains('.mp3') || - value.contains('.m4a') || - value.contains('.wav') || - value.contains('.ogg') || - value.contains('.flac') || - value.contains('.aac') || - value.contains('.opus') || - value.contains('.wma'); + final type = post.fileType.toLowerCase().trim(); + if (type == 'audio') return true; + if (type == 'video' || type == 'photo' || type == 'archive' || type == 'gif') { + return false; + } + + final fileExt = _extractExtension(post.fileUrl); + if (_audioExtensions.contains(fileExt)) return true; + if (_videoExtensions.contains(fileExt)) return false; + + final sampleExt = _extractExtension(post.sampleUrl); + if (_audioExtensions.contains(sampleExt)) return true; + if (_videoExtensions.contains(sampleExt)) return false; + + return false; } static bool _isVideo(Post post) { - if (_isAudio(post)) return false; - final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); - return value.contains('video') || - value.contains('.webm') || - value.contains('.mp4') || - value.contains('.mov') || - value.contains('.mkv') || - value.contains('.avi') || - value.contains('.flv') || - value.contains('.wmv') || - value.contains('.m4v') || - value.contains('.ts'); + final type = post.fileType.toLowerCase().trim(); + if (type == 'video') return true; + if (type == 'audio' || type == 'photo' || type == 'archive' || type == 'gif') { + return false; + } + + final fileExt = _extractExtension(post.fileUrl); + if (_videoExtensions.contains(fileExt)) return true; + if (_audioExtensions.contains(fileExt)) return false; + + final sampleExt = _extractExtension(post.sampleUrl); + if (_videoExtensions.contains(sampleExt)) return true; + if (_audioExtensions.contains(sampleExt)) return false; + + return false; } static bool _isGif(Post post) { - final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); - return value.contains('gif') || value.contains('.gif'); + final type = post.fileType.toLowerCase().trim(); + if (type == 'gif') return true; + final fileExt = _extractExtension(post.fileUrl); + if (fileExt == '.gif') return true; + final sampleExt = _extractExtension(post.sampleUrl); + return sampleExt == '.gif'; } static bool _looksLikeVideoUrl(String url) { - final value = url.toLowerCase(); - return value.contains('.webm') || - value.contains('.mp4') || - value.contains('.mov') || - value.contains('.mkv') || - value.contains('.avi') || - value.contains('.flv') || - value.contains('.wmv') || - value.contains('.m4v') || - value.contains('.ts'); + final ext = _extractExtension(url); + return _videoExtensions.contains(ext); } static bool _looksLikeAudioUrl(String url) { - final value = url.toLowerCase(); - return value.contains('.mp3') || - value.contains('.m4a') || - value.contains('.wav') || - value.contains('.ogg') || - value.contains('.flac') || - value.contains('.aac') || - value.contains('.opus') || - value.contains('.wma'); + final ext = _extractExtension(url); + return _audioExtensions.contains(ext); } } + diff --git a/lib/features/providers/presentation/provider_form_screen.dart b/lib/features/providers/presentation/provider_form_screen.dart index 028943a..81621a5 100644 --- a/lib/features/providers/presentation/provider_form_screen.dart +++ b/lib/features/providers/presentation/provider_form_screen.dart @@ -288,14 +288,6 @@ class _ProviderFormScreenState extends ConsumerState { value: 'realbooru_html', child: Text('Realbooru HTML'), ), - DropdownMenuItem( - value: 'kemono', - child: Text('Kemono Party artists'), - ), - DropdownMenuItem( - value: 'coomer', - child: Text('Coomer Party artists'), - ), DropdownMenuItem( value: 'custom', child: Text('Custom REST API'), diff --git a/lib/features/providers/presentation/widgets/provider_card.dart b/lib/features/providers/presentation/widgets/provider_card.dart index 3c1cb67..44637b8 100644 --- a/lib/features/providers/presentation/widgets/provider_card.dart +++ b/lib/features/providers/presentation/widgets/provider_card.dart @@ -305,16 +305,6 @@ class ProviderCard extends StatelessWidget { Icons.pets_rounded, 'e621 / e926', ), - 'kemono' => ( - const Color(0xFFEC4899), - Icons.palette_rounded, - 'Kemono', - ), - 'coomer' => ( - const Color(0xFF06B6D4), - Icons.collections_rounded, - 'Coomer', - ), _ => ( const Color(0xFF6366F1), Icons.hub_rounded, diff --git a/pubspec.yaml b/pubspec.yaml index 57a1144..6ada25c 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: 3.5.3+79 +version: 3.5.4+80 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/pawchive_provider_test.dart b/test/backend/pawchive_provider_test.dart index b3691de..5c5e8d2 100644 --- a/test/backend/pawchive_provider_test.dart +++ b/test/backend/pawchive_provider_test.dart @@ -111,5 +111,28 @@ void main() { expect(MediaUrlSelector.isAudio(photoPost), isFalse); expect(MediaUrlSelector.isVideo(photoPost), isFalse); }); + + test('MediaUrlSelector classifies mp4 containing "audio" in query parameter as video and not audio', () { + final trickyVideoPost = Post( + id: 'patreon:123:456:3', + providerId: 'pawchive', + providerName: 'Pawchive', + previewUrl: '', + sampleUrl: '', + fileUrl: + 'https://file.pawchive.pw/data/b4/65/b465cb03ab7b26c0a677db3ae43500d1ef71e3e25151f160cda244c1fb40cfae.mp4?f=nsfw%202%20-%20audio%20spicy.mp4', + tags: const ['patreon', 'artist', 'nsfw 2 - audio spicy'], + rating: 'unknown', + width: 1920, + height: 1080, + createdAt: DateTime.now(), + fileType: 'video', + score: 0, + ); + + expect(MediaUrlSelector.isVideo(trickyVideoPost), isTrue); + expect(MediaUrlSelector.isAudio(trickyVideoPost), isFalse); + expect(MediaUrlSelector.video(trickyVideoPost), contains(trickyVideoPost.fileUrl)); + }); } diff --git a/test/backend/provider_parser_test.dart b/test/backend/provider_parser_test.dart index 7160899..dd4acf7 100644 --- a/test/backend/provider_parser_test.dart +++ b/test/backend/provider_parser_test.dart @@ -6,7 +6,6 @@ import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart'; import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart'; import 'package:gel_rule_app/backend/models/content_provider_config.dart'; import 'package:gel_rule_app/backend/providers/custom_provider.dart'; -import 'package:gel_rule_app/backend/providers/kemono_provider.dart'; import 'package:gel_rule_app/backend/providers/provider_factory.dart'; import 'package:gel_rule_app/backend/providers/realbooru_html_provider.dart'; import 'package:gel_rule_app/backend/repositories/provider_repository.dart'; @@ -307,7 +306,7 @@ void main() { expect(provider, isA()); }); - test('provider factory creates Kemono and Coomer artist providers', () { + test('provider factory treats removed Kemono and Coomer as unsupported', () { final now = DateTime(2026); for (final apiType in ['kemono', 'coomer']) { final provider = ProviderFactory().create(ContentProviderConfig( @@ -323,7 +322,7 @@ void main() { updatedAt: now, )); - expect(provider, isA()); + expect(provider, isA()); } }); }