feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements
This commit is contained in:
@@ -12,6 +12,7 @@ import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../models/download_task.dart';
|
||||
import '../models/downloaded_media.dart';
|
||||
import '../models/post.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
import '../repositories/downloaded_media_repository.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
@@ -188,6 +189,12 @@ final downloadedMediaByKeyProvider =
|
||||
return result is Success<DownloadedMedia?> ? result.data : null;
|
||||
});
|
||||
|
||||
final postMediaHeadersProvider =
|
||||
FutureProvider.family<Map<String, String>, Post>((ref, post) async {
|
||||
final result = await ref.watch(providerManagerProvider).getMediaHeaders(post);
|
||||
return result is Success<Map<String, String>> ? result.data : const {};
|
||||
});
|
||||
|
||||
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
|
||||
final manager = ref.watch(downloadManagerServiceProvider);
|
||||
return manager.stream;
|
||||
|
||||
@@ -24,6 +24,14 @@ abstract class ContentProvider {
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
|
||||
abstract class PostPageProvider {
|
||||
String? postPageUrl(Post post);
|
||||
}
|
||||
|
||||
abstract class MediaHeadersProvider {
|
||||
Map<String, String> mediaHeaders(Post post);
|
||||
}
|
||||
|
||||
abstract class CommentProvider {
|
||||
Future<List<PostComment>> getComments(String postId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
class DanbooruProvider
|
||||
implements ContentProvider, CommentProvider, PostPageProvider {
|
||||
DanbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -27,6 +28,9 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class E621Provider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
implements
|
||||
ContentProvider,
|
||||
TagSuggestionProvider,
|
||||
CommentProvider,
|
||||
PostPageProvider {
|
||||
E621Provider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -29,6 +33,9 @@ class E621Provider
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -15,7 +15,9 @@ class GelbooruProvider
|
||||
ContentProvider,
|
||||
CommentProvider,
|
||||
TagSuggestionProvider,
|
||||
TagMetadataProvider {
|
||||
TagMetadataProvider,
|
||||
PostPageProvider,
|
||||
MediaHeadersProvider {
|
||||
GelbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -36,6 +38,20 @@ class GelbooruProvider
|
||||
Dio get dio => _dio;
|
||||
Map<String, String> get queryParameters => _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) {
|
||||
final host = Uri.tryParse(baseUrl)?.host;
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -106,10 +106,15 @@ class KemonoProvider
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
},
|
||||
);
|
||||
final artists = _artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null || service.isEmpty || artist.service == service)
|
||||
.toList(growable: false);
|
||||
final artists = _rankArtists(
|
||||
_artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service == service)
|
||||
.toList(growable: false),
|
||||
searchText,
|
||||
);
|
||||
if (artists.isNotEmpty) return artists;
|
||||
} catch (_) {
|
||||
// Fall through to the indexed creator endpoints.
|
||||
@@ -126,7 +131,7 @@ class KemonoProvider
|
||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||
},
|
||||
);
|
||||
return _artistsFromResponse(response.data);
|
||||
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
} catch (_) {
|
||||
return _listArtistsFromPublicApi(
|
||||
service: service,
|
||||
@@ -177,6 +182,7 @@ class KemonoProvider
|
||||
));
|
||||
}
|
||||
if (mapped.isNotEmpty || posts.isEmpty) {
|
||||
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return mapped.take(limit).toList(growable: false);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -290,7 +296,36 @@ class KemonoProvider
|
||||
artist.displayName.toLowerCase().contains(queryText) ||
|
||||
artist.id.toLowerCase().contains(queryText)))
|
||||
.toList(growable: false);
|
||||
return items.skip(offset).take(limit).toList(growable: false);
|
||||
final ranked = _rankArtists(items, queryText);
|
||||
return ranked.skip(offset).take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
List<ArtistProfile> _rankArtists(List<ArtistProfile> 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<dynamic> _postItems(dynamic data) {
|
||||
|
||||
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class MoebooruProvider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
implements
|
||||
ContentProvider,
|
||||
TagSuggestionProvider,
|
||||
CommentProvider,
|
||||
PostPageProvider {
|
||||
MoebooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -29,6 +33,9 @@ class MoebooruProvider
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -7,8 +7,9 @@ import 'e621_provider.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
import 'kemono_provider.dart';
|
||||
import 'moebooru_provider.dart';
|
||||
import 'realbooru_provider.dart';
|
||||
import 'realbooru_html_provider.dart';
|
||||
import 'rule34_provider.dart';
|
||||
import 'rule34_paheal_provider.dart';
|
||||
|
||||
class ProviderFactory {
|
||||
ContentProvider create(ContentProviderConfig config) {
|
||||
@@ -41,13 +42,20 @@ class ProviderFactory {
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'realbooru':
|
||||
return RealbooruProvider(
|
||||
case 'paheal':
|
||||
case 'rule34_paheal':
|
||||
return Rule34PahealProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
);
|
||||
case 'realbooru_html':
|
||||
return RealbooruHtmlProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'danbooru':
|
||||
return DanbooruProvider(
|
||||
|
||||
@@ -18,12 +18,30 @@ class ProviderManager {
|
||||
|
||||
final ProviderRepository _repository;
|
||||
final ProviderFactory _factory;
|
||||
final Map<String, _SuggestionCacheEntry> _suggestionCache = {};
|
||||
final Set<String> _softRetryKeys = {};
|
||||
final _softRetryController =
|
||||
StreamController<ProviderSoftRetryResult>.broadcast();
|
||||
_SuggestionProviderCacheEntry? _suggestionProviderCache;
|
||||
|
||||
static const _suggestionCacheTtl = Duration(seconds: 45);
|
||||
static const _suggestionProviderCacheTtl = Duration(seconds: 60);
|
||||
static const _suggestionTimeout = Duration(milliseconds: 1500);
|
||||
|
||||
Stream<ProviderSoftRetryResult> get softRetryResults =>
|
||||
_softRetryController.stream;
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadConfigs({
|
||||
bool enabledOnly = true,
|
||||
}) async {
|
||||
await _repository.ensureSeedProviders();
|
||||
return _repository.getProviders(enabledOnly: enabledOnly);
|
||||
final result = await _repository.getProviders(enabledOnly: enabledOnly);
|
||||
if (result is Error<List<ContentProviderConfig>>) return result;
|
||||
final configs = (result as Success<List<ContentProviderConfig>>)
|
||||
.data
|
||||
.where((config) => !_isLegacyRemovedConfig(config))
|
||||
.toList(growable: false);
|
||||
return Success(configs);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadFeedConfigs({
|
||||
@@ -97,20 +115,34 @@ class ProviderManager {
|
||||
Failure(code: 'not_found', message: 'Provider not found'),
|
||||
);
|
||||
}
|
||||
return _repository.saveProvider(
|
||||
return _repository
|
||||
.saveProvider(
|
||||
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
|
||||
);
|
||||
)
|
||||
.then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
},
|
||||
onError: Error<void>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
|
||||
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
|
||||
return _repository
|
||||
.saveProvider(config.copyWith(updatedAt: DateTime.now()))
|
||||
.then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteProvider(String id) =>
|
||||
_repository.deleteProvider(id);
|
||||
Future<Result<void>> deleteProvider(String id) {
|
||||
return _repository.deleteProvider(id).then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
|
||||
final providersResult = await activeProviders();
|
||||
@@ -186,6 +218,14 @@ class ProviderManager {
|
||||
errorMessage: 'Search failed',
|
||||
),
|
||||
);
|
||||
_scheduleSoftSearchRetry(
|
||||
provider,
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
}
|
||||
}
|
||||
final posts = providerId == null
|
||||
@@ -228,6 +268,75 @@ class ProviderManager {
|
||||
return hash;
|
||||
}
|
||||
|
||||
void _scheduleSoftSearchRetry(
|
||||
ContentProvider provider, {
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
required int limit,
|
||||
required String? rating,
|
||||
required TopPeriodFilter topPeriod,
|
||||
}) {
|
||||
final key = [
|
||||
provider.id,
|
||||
page,
|
||||
limit,
|
||||
rating ?? '',
|
||||
topPeriod.name,
|
||||
...tags,
|
||||
].join('\u001f');
|
||||
if (!_softRetryKeys.add(key)) return;
|
||||
unawaited(Future<void>.delayed(const Duration(milliseconds: 900), () async {
|
||||
try {
|
||||
final posts = await provider.searchPosts(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: posts.length,
|
||||
),
|
||||
);
|
||||
await _repository.saveHealth(
|
||||
ProviderHealth(
|
||||
providerId: provider.id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: 0,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (posts.isNotEmpty) {
|
||||
_softRetryController.add(
|
||||
ProviderSoftRetryResult(
|
||||
providerId: provider.id,
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
posts: posts,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: 0,
|
||||
lastErrorMessage: 'Search failed after retry',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_softRetryKeys.remove(key);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Future<Result<List<PostComment>>> getComments(
|
||||
String providerId,
|
||||
String postId,
|
||||
@@ -282,6 +391,39 @@ class ProviderManager {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<String?>> getPostPageUrl(Post post) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches =
|
||||
providers.where((provider) => provider.id == post.providerId);
|
||||
if (matches.isEmpty || matches.first is! PostPageProvider) {
|
||||
return const Success(null);
|
||||
}
|
||||
return Success((matches.first as PostPageProvider).postPageUrl(post));
|
||||
}
|
||||
|
||||
Future<Result<Map<String, String>>> getMediaHeaders(Post post) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches =
|
||||
providers.where((provider) => provider.id == post.providerId);
|
||||
if (matches.isNotEmpty && matches.first is MediaHeadersProvider) {
|
||||
return Success(
|
||||
(matches.first as MediaHeadersProvider).mediaHeaders(post),
|
||||
);
|
||||
}
|
||||
return const Success({
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Post>> enrichPostTags(Post post) async {
|
||||
final hasUsefulGroups = post.tagGroups.entries.any(
|
||||
(entry) => entry.key != 'general' && entry.value.isNotEmpty,
|
||||
@@ -313,29 +455,106 @@ class ProviderManager {
|
||||
String query, {
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) return const Success([]);
|
||||
final providersResult = await activeSuggestionProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
if (providers.isEmpty) return const Success([]);
|
||||
|
||||
final cappedLimit = limit.clamp(1, 50);
|
||||
final providerIds = providers.map((provider) => provider.id).join(',');
|
||||
final cacheKey = '$normalizedQuery|$providerIds|$cappedLimit';
|
||||
final cached = _suggestionCache[cacheKey];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.createdAt) < _suggestionCacheTtl) {
|
||||
return Success(cached.items.take(cappedLimit).toList(growable: false));
|
||||
}
|
||||
|
||||
final providerResults = await Future.wait(
|
||||
providers.map(
|
||||
(provider) => _suggestionsFromProvider(
|
||||
provider as TagSuggestionProvider,
|
||||
normalizedQuery,
|
||||
cappedLimit,
|
||||
),
|
||||
),
|
||||
);
|
||||
final suggestions = <String, TagSuggestion>{};
|
||||
for (final items in providerResults) {
|
||||
for (final item in items) {
|
||||
final nameKey = item.name.trim().toLowerCase();
|
||||
if (nameKey.isEmpty) continue;
|
||||
final current = suggestions[nameKey];
|
||||
if (current == null || item.postCount > current.postCount) {
|
||||
suggestions[nameKey] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
final values = suggestions.values.toList()
|
||||
..sort((a, b) {
|
||||
final count = b.postCount.compareTo(a.postCount);
|
||||
return count != 0 ? count : a.name.compareTo(b.name);
|
||||
});
|
||||
_suggestionCache[cacheKey] = _SuggestionCacheEntry(
|
||||
DateTime.now(),
|
||||
values,
|
||||
);
|
||||
_pruneSuggestionCache();
|
||||
return Success(values.take(cappedLimit).toList());
|
||||
}
|
||||
|
||||
Future<List<TagSuggestion>> _suggestionsFromProvider(
|
||||
TagSuggestionProvider provider,
|
||||
String query,
|
||||
int limit,
|
||||
) async {
|
||||
try {
|
||||
return await provider
|
||||
.suggestTags(query, limit: limit)
|
||||
.timeout(_suggestionTimeout);
|
||||
} catch (_) {
|
||||
// Suggestions are non-critical; a failed provider should not affect UI.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
void _pruneSuggestionCache() {
|
||||
final now = DateTime.now();
|
||||
_suggestionCache.removeWhere(
|
||||
(_, entry) => now.difference(entry.createdAt) >= _suggestionCacheTtl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProvider>>> activeSuggestionProviders() async {
|
||||
final cached = _suggestionProviderCache;
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.createdAt) <
|
||||
_suggestionProviderCacheTtl) {
|
||||
return Success(cached.providers);
|
||||
}
|
||||
|
||||
final providersResult = await activeFeedProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>)
|
||||
.data
|
||||
.whereType<TagSuggestionProvider>()
|
||||
.toList();
|
||||
final suggestions = <String, TagSuggestion>{};
|
||||
for (final provider in providers) {
|
||||
try {
|
||||
final items = await provider.suggestTags(query, limit: limit);
|
||||
for (final item in items) {
|
||||
suggestions.putIfAbsent(
|
||||
'${item.providerId}:${item.name}', () => item);
|
||||
}
|
||||
} catch (_) {
|
||||
// Suggestions are non-critical; a failed provider should not affect UI.
|
||||
}
|
||||
}
|
||||
final values = suggestions.values.toList()
|
||||
..sort((a, b) => b.postCount.compareTo(a.postCount));
|
||||
return Success(values.take(limit).toList());
|
||||
final providers = <ContentProvider>[
|
||||
for (final provider
|
||||
in (providersResult as Success<List<ContentProvider>>).data)
|
||||
if (provider is TagSuggestionProvider) provider,
|
||||
];
|
||||
_suggestionProviderCache = _SuggestionProviderCacheEntry(
|
||||
DateTime.now(),
|
||||
providers,
|
||||
);
|
||||
return Success(providers);
|
||||
}
|
||||
|
||||
void _clearSuggestionCaches() {
|
||||
_suggestionCache.clear();
|
||||
_suggestionProviderCache = null;
|
||||
}
|
||||
|
||||
Future<List<R>> _runLimited<T, R>(
|
||||
@@ -367,4 +586,44 @@ class ProviderManager {
|
||||
static bool _isFeedConfig(ContentProviderConfig config) {
|
||||
return !_isArtistConfig(config);
|
||||
}
|
||||
|
||||
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
|
||||
final id = config.id.toLowerCase();
|
||||
final type = config.apiType.toLowerCase();
|
||||
return id == 'cosbooru' || type == 'realbooru';
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionCacheEntry {
|
||||
const _SuggestionCacheEntry(this.createdAt, this.items);
|
||||
|
||||
final DateTime createdAt;
|
||||
final List<TagSuggestion> items;
|
||||
}
|
||||
|
||||
class _SuggestionProviderCacheEntry {
|
||||
const _SuggestionProviderCacheEntry(this.createdAt, this.providers);
|
||||
|
||||
final DateTime createdAt;
|
||||
final List<ContentProvider> providers;
|
||||
}
|
||||
|
||||
class ProviderSoftRetryResult {
|
||||
const ProviderSoftRetryResult({
|
||||
required this.providerId,
|
||||
required this.tags,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.topPeriod,
|
||||
required this.posts,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final List<String> tags;
|
||||
final int page;
|
||||
final int limit;
|
||||
final TopPeriodFilter topPeriod;
|
||||
final String? rating;
|
||||
final List<Post> posts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class RealbooruHtmlProvider
|
||||
implements
|
||||
ContentProvider,
|
||||
PostPageProvider,
|
||||
MediaHeadersProvider,
|
||||
TagSuggestionProvider {
|
||||
RealbooruHtmlProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
}) : _dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
if (tags.isEmpty) 'all' else ...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
].join(' ');
|
||||
final response = await _dio.get<String>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'post',
|
||||
's': 'list',
|
||||
'tags': queryTags,
|
||||
'pid': page * limit,
|
||||
},
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseList(response.data ?? '').take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/index.php',
|
||||
queryParameters: {'page': 'post', 's': 'view', 'id': id},
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseDetails(id, response.data ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final posts = await searchPosts(tags: const ['all'], page: 0, limit: 1);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'realbooru-html',
|
||||
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) => {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
|
||||
'Accept':
|
||||
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Referer': postPageUrl(post),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<List<TagSuggestion>> suggestTags(String query,
|
||||
{int limit = 20}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'autocomplete',
|
||||
'term': trimmed,
|
||||
},
|
||||
);
|
||||
final values = _autocompleteItems(response.data);
|
||||
return values
|
||||
.map((name) => TagSuggestion(
|
||||
name: name,
|
||||
category: TagCategory.unknown,
|
||||
postCount: 0,
|
||||
providerId: id,
|
||||
))
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
trimmed.toLowerCase(),
|
||||
))
|
||||
.take(limit.clamp(1, 50))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<Post> _parseList(String html) {
|
||||
final posts = <Post>[];
|
||||
final pattern = RegExp(
|
||||
r'''<div[^>]*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?<a[^>]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final match in pattern.allMatches(html)) {
|
||||
final id = match.group(1) ?? '';
|
||||
final imgTag = match.group(0) ?? '';
|
||||
final previewUrl = _absolute(_decode(match.group(3) ?? ''));
|
||||
final tags = _tags(_attribute(imgTag, 'title'));
|
||||
final isVideo = imgTag.contains('#0000ff') ||
|
||||
tags.contains('webm') ||
|
||||
tags.contains('video');
|
||||
final isGif =
|
||||
!isVideo && (tags.contains('gif') || tags.contains('animated_gif'));
|
||||
final derivedOriginal =
|
||||
isVideo ? '' : _imageUrlFromThumbnail(previewUrl, gif: isGif);
|
||||
posts.add(
|
||||
Post(
|
||||
id: id,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
|
||||
fileUrl: isVideo ? '' : derivedOriginal,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: postPageUrl(Post(
|
||||
id: id,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
|
||||
fileUrl: isVideo ? '' : derivedOriginal,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: isVideo
|
||||
? 'video'
|
||||
: isGif
|
||||
? 'gif'
|
||||
: 'image',
|
||||
score: 0,
|
||||
)),
|
||||
createdAt: DateTime.now(),
|
||||
fileType: isVideo
|
||||
? 'video'
|
||||
: isGif
|
||||
? 'gif'
|
||||
: 'image',
|
||||
score: 0,
|
||||
tagGroups: _tagGroups(tags),
|
||||
),
|
||||
);
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
Post? _parseDetails(String postId, String html) {
|
||||
final imageTag = RegExp(
|
||||
r'''<(?:img|video)[^>]+(?:id=["']image["'][^>]*|[^>]*id=["']image["'])''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(0);
|
||||
final originalUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=["']([^"']+)["'][^>]*>\s*Original\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final videoSources = RegExp(
|
||||
r'''<source[^>]+src=["']([^"']+\.(?:webm|mp4)[^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
)
|
||||
.allMatches(html)
|
||||
.map((match) => _absolute(match.group(1) ?? ''))
|
||||
.where((url) => url.isNotEmpty)
|
||||
.toList();
|
||||
videoSources.sort((left, right) {
|
||||
final leftWebm = left.toLowerCase().split('?').first.endsWith('.webm');
|
||||
final rightWebm = right.toLowerCase().split('?').first.endsWith('.webm');
|
||||
if (leftWebm == rightWebm) return 0;
|
||||
return leftWebm ? -1 : 1;
|
||||
});
|
||||
final imageUrl = _absolute(_attribute(imageTag ?? '', 'src'));
|
||||
final mediaUrl = videoSources.isNotEmpty
|
||||
? videoSources.first
|
||||
: originalUrl.isNotEmpty
|
||||
? originalUrl
|
||||
: imageUrl;
|
||||
final sampleUrl = videoSources.length > 1 ? videoSources[1] : mediaUrl;
|
||||
final source = _attribute(
|
||||
RegExp(r'''<input[^>]+id=["']source["'][^>]*>''', caseSensitive: false)
|
||||
.firstMatch(html)
|
||||
?.group(0) ??
|
||||
'',
|
||||
'value',
|
||||
);
|
||||
final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty
|
||||
? _attribute(imageTag ?? '', 'alt')
|
||||
: RegExp(
|
||||
r'''<textarea[^>]+id=["']tags["'][^>]*>([\s\S]*?)</textarea>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'';
|
||||
final tags = _tags(tagsText);
|
||||
if (mediaUrl.isEmpty) return null;
|
||||
return Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: imageUrl.isEmpty ? mediaUrl : imageUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: source.isEmpty ? postPageUrlById(postId) : source,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType(mediaUrl),
|
||||
score: 0,
|
||||
tagGroups: _tagGroups(tags),
|
||||
);
|
||||
}
|
||||
|
||||
String postPageUrlById(String postId) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=$postId';
|
||||
|
||||
String _attribute(String source, String name) {
|
||||
final match = RegExp(
|
||||
'''$name\\s*=\\s*["']([^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(source);
|
||||
return _decode(match?.group(1) ?? '');
|
||||
}
|
||||
|
||||
List<String> _tags(String value) {
|
||||
return _decode(value)
|
||||
.replaceAll(',', ' ')
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_'))
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Map<String, List<String>> _tagGroups(List<String> tags) =>
|
||||
tags.isEmpty ? const {} : {'general': tags};
|
||||
|
||||
String _fileType(String url) {
|
||||
final lower = url.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
String _absolute(String url) {
|
||||
final value = _decode(url.trim());
|
||||
if (value.startsWith('//')) return _normalizeUrl('https:$value');
|
||||
if (value.startsWith('/')) return _normalizeUrl('$baseUrl$value');
|
||||
return _normalizeUrl(value);
|
||||
}
|
||||
|
||||
String _normalizeUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return url;
|
||||
final normalizedPath = uri.path.replaceFirst(RegExp(r'^/+'), '/');
|
||||
return uri.replace(path: normalizedPath).toString();
|
||||
}
|
||||
|
||||
String _imageUrlFromThumbnail(String thumbnailUrl, {bool gif = false}) {
|
||||
final uri = Uri.tryParse(thumbnailUrl);
|
||||
if (uri == null) return '';
|
||||
final match = RegExp(r'/thumbnails/([^/]+)/([^/]+)/thumbnail_([^/.]+)\.')
|
||||
.firstMatch(uri.path);
|
||||
if (match == null) return '';
|
||||
final extension = gif ? 'gif' : 'jpeg';
|
||||
final path =
|
||||
'/images/${match.group(1)}/${match.group(2)}/${match.group(3)}.$extension';
|
||||
return uri.replace(path: path, query: '').toString();
|
||||
}
|
||||
|
||||
String _decode(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
List<String> _autocompleteItems(dynamic data) {
|
||||
final source = data is String ? jsonDecode(data) : data;
|
||||
if (source is List) {
|
||||
return source
|
||||
.map((item) => item.toString().trim().replaceAll(' ', '_'))
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../mappers/gelbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
|
||||
class RealbooruProvider extends GelbooruProvider {
|
||||
RealbooruProvider({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.baseUrl,
|
||||
required super.dioClient,
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
if (topPeriod == TopPeriodFilter.allTime) 'sort:score:desc',
|
||||
];
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'pid': page,
|
||||
'limit': limit.clamp(1, 100),
|
||||
'tags': queryTags.join(' '),
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'id': id,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'limit': 1,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'realbooru-dapi',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'realbooru-dapi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfSearchFailed(dynamic data) {
|
||||
if (data is Map) {
|
||||
final success = data['success']?.toString().toLowerCase();
|
||||
if (success == 'false') {
|
||||
throw ProviderUnavailableException(
|
||||
(data['message'] ??
|
||||
data['reason'] ??
|
||||
'Realbooru search is unavailable')
|
||||
.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
final text = data?.toString().toLowerCase() ?? '';
|
||||
if (text.contains('success="false"') || text.contains("success='false'")) {
|
||||
final message = RegExp(r'''(?:message|reason)\s*=\s*["']([^"']+)["']''')
|
||||
.firstMatch(data?.toString() ?? '');
|
||||
throw ProviderUnavailableException(
|
||||
message?.group(1) ?? 'Realbooru search is unavailable',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class Rule34PahealProvider implements ContentProvider, PostPageProvider {
|
||||
Rule34PahealProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
}) : _dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
// Paheal's public HTML list ignores common sort/top query parameters and
|
||||
// exposes no stable top-period endpoint, so non-latest filters fall back
|
||||
// to the regular list instead of pretending to support ranking.
|
||||
final query = tags
|
||||
.where((tag) => tag.trim().isNotEmpty && tag.trim() != 'all')
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_'))
|
||||
.join('_');
|
||||
final path = query.isEmpty
|
||||
? '/post/list/${page + 1}'
|
||||
: '/post/list/$query/${page + 1}';
|
||||
final response = await _dio.get<String>(
|
||||
path,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseList(response.data ?? '').take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/post/view/$id',
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseSingle(id, response.data ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final posts = await searchPosts(tags: const [], page: 0, limit: 1);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'paheal-html',
|
||||
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'paheal-html',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/post/view/${post.id}';
|
||||
|
||||
List<Post> _parseList(String html) {
|
||||
final posts = <Post>[];
|
||||
final pattern = RegExp(
|
||||
r'''<div[^>]+class=['"][^'"]*\bshm-thumb\b[^'"]*['"][\s\S]*?</div>''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final match in pattern.allMatches(html)) {
|
||||
final block = match.group(0) ?? '';
|
||||
final postId = _attribute(block, 'data-post-id');
|
||||
final fileUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*File Only\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(block)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final previewUrl = _absolute(
|
||||
RegExp(r'''<img[^>]+src=['"]([^'"]+)['"]''', caseSensitive: false)
|
||||
.firstMatch(block)
|
||||
?.group(1) ??
|
||||
'',
|
||||
);
|
||||
if (postId.isEmpty || (fileUrl.isEmpty && previewUrl.isEmpty)) continue;
|
||||
final tags = _tags(_attribute(block, 'data-tags'));
|
||||
final ext = _attribute(block, 'data-ext');
|
||||
final dimensions = _dimensions(_attribute(block, 'title'));
|
||||
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
|
||||
posts.add(
|
||||
Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
|
||||
sampleUrl: mediaUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: dimensions.$1,
|
||||
height: dimensions.$2,
|
||||
source: '$baseUrl/post/view/$postId',
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType('$ext $mediaUrl'),
|
||||
score: 0,
|
||||
tagGroups: tags.isEmpty ? const {} : {'general': tags},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (posts.isNotEmpty) return posts;
|
||||
final singleId = RegExp(r'''/post/view/(\d+)''').firstMatch(html)?.group(1);
|
||||
final single = singleId == null ? null : _parseSingle(singleId, html);
|
||||
return single == null ? const [] : [single];
|
||||
}
|
||||
|
||||
Post? _parseSingle(String postId, String html) {
|
||||
final fileUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*(?:File|Image) Only\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
RegExp(
|
||||
r'''<(?:img|video|source)[^>]+src=['"]([^'"]+(?:jpg|jpeg|png|gif|webm|mp4)[^'"]*)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final previewUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<meta[^>]+property=['"]og:image['"][^>]+content=['"]([^'"]+)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final tagsText = RegExp(
|
||||
r'''<meta[^>]+name=['"]keywords['"][^>]+content=['"]([^'"]+)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'';
|
||||
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
|
||||
if (mediaUrl.isEmpty) return null;
|
||||
final tags = _tags(tagsText);
|
||||
return Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
|
||||
sampleUrl: mediaUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: '$baseUrl/post/view/$postId',
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType(mediaUrl),
|
||||
score: 0,
|
||||
tagGroups: tags.isEmpty ? const {} : {'general': tags},
|
||||
);
|
||||
}
|
||||
|
||||
String _attribute(String source, String name) {
|
||||
return _decode(
|
||||
RegExp(
|
||||
'''$name\\s*=\\s*['"]([^'"]*)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(source)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
(int, int) _dimensions(String value) {
|
||||
final match = RegExp(r'(\d+)x(\d+)').firstMatch(value);
|
||||
return (
|
||||
int.tryParse(match?.group(1) ?? '') ?? 0,
|
||||
int.tryParse(match?.group(2) ?? '') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _tags(String value) {
|
||||
return _decode(value)
|
||||
.replaceAll(',', ' ')
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_').toLowerCase())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.contains('webm') || lower.contains('mp4')) return 'video';
|
||||
if (lower.contains('gif')) return 'gif';
|
||||
if (lower.contains('png')) return 'png';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
String _absolute(String url) {
|
||||
final value = _decode(url.trim());
|
||||
if (value.isEmpty) return '';
|
||||
if (value.startsWith('//')) return 'https:$value';
|
||||
if (value.startsWith('/')) return '$baseUrl$value';
|
||||
return value;
|
||||
}
|
||||
|
||||
String _decode(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@ class Rule34Provider extends GelbooruProvider {
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'https://rule34.xxx/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) => const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -111,8 +111,8 @@ class ProviderRepository {
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru',
|
||||
enabled: false,
|
||||
apiType: 'realbooru_html',
|
||||
enabled: true,
|
||||
priority: 7,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
@@ -132,14 +132,16 @@ class ProviderRepository {
|
||||
updatedAt: now,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'cosbooru',
|
||||
name: 'CosBooru',
|
||||
baseUrl: 'https://cos.lycore.co',
|
||||
apiType: 'danbooru',
|
||||
id: 'paheal',
|
||||
name: 'Rule34 Paheal',
|
||||
baseUrl: 'https://rule34.paheal.net',
|
||||
apiType: 'paheal',
|
||||
enabled: true,
|
||||
priority: 9,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
@@ -179,34 +181,12 @@ class ProviderRepository {
|
||||
final existingProviders =
|
||||
await isar.providerConfigEntitys.where().findAll();
|
||||
for (final provider in existingProviders) {
|
||||
if (provider.providerId == 'paheal') {
|
||||
await isar.providerConfigEntitys.delete(provider.isarId);
|
||||
if (provider.providerId == 'cosbooru') {
|
||||
provider.enabled = false;
|
||||
provider.updatedAt = DateTime.now();
|
||||
await isar.providerConfigEntitys.put(provider);
|
||||
}
|
||||
}
|
||||
await isar.providerHealthEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.providerDiagnosticsEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.cachedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.favoriteEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.collectionPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
await isar.viewedPostEntitys
|
||||
.filter()
|
||||
.providerIdEqualTo('paheal')
|
||||
.deleteAll();
|
||||
final seeds = seedProviders();
|
||||
for (final seed in seeds) {
|
||||
final exists = await isar.providerConfigEntitys
|
||||
@@ -216,13 +196,10 @@ class ProviderRepository {
|
||||
if (exists == null) {
|
||||
await isar.providerConfigEntitys
|
||||
.put(ProviderConfigEntity.fromModel(seed));
|
||||
} else if (seed.id == 'realbooru' && exists.enabled) {
|
||||
exists.enabled = false;
|
||||
exists.updatedAt = DateTime.now();
|
||||
await isar.providerConfigEntitys.put(exists);
|
||||
} else if (seed.id == 'cosbooru' &&
|
||||
(exists.baseUrl != seed.baseUrl ||
|
||||
exists.apiType != seed.apiType)) {
|
||||
} else if (seed.id == 'realbooru' &&
|
||||
(exists.apiType != seed.apiType ||
|
||||
exists.baseUrl != seed.baseUrl ||
|
||||
exists.name != seed.name)) {
|
||||
exists.name = seed.name;
|
||||
exists.baseUrl = seed.baseUrl;
|
||||
exists.apiType = seed.apiType;
|
||||
|
||||
@@ -23,11 +23,12 @@ class SearchRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recent({int limit = 20}) {
|
||||
Future<Result<List<SearchHistory>>> recent({int? limit}) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.searchHistoryEntitys.where().findAll();
|
||||
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
|
||||
return items.take(limit).map((entity) => entity.toModel()).toList();
|
||||
final limited = limit == null ? items : items.take(limit);
|
||||
return limited.map((entity) => entity.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,12 @@ class DownloadManagerService {
|
||||
final DownloadedMediaService? _downloadedMediaService;
|
||||
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
||||
final Map<String, DownloadTask> _tasks = {};
|
||||
final Set<String> _runningTaskIds = {};
|
||||
bool _queuePaused = false;
|
||||
|
||||
Stream<List<DownloadTask>> get stream => _controller.stream;
|
||||
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
|
||||
bool get queuePaused => _queuePaused;
|
||||
|
||||
Future<DownloadTask> start(Post post) async {
|
||||
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
|
||||
@@ -30,7 +33,7 @@ class DownloadManagerService {
|
||||
status: DownloadTaskStatus.queued,
|
||||
);
|
||||
_set(task);
|
||||
unawaited(_run(task));
|
||||
_pumpQueue();
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -49,10 +52,21 @@ class DownloadManagerService {
|
||||
openAfterDownload: openAfterDownload,
|
||||
);
|
||||
_set(task);
|
||||
unawaited(_run(task));
|
||||
_pumpQueue();
|
||||
return task;
|
||||
}
|
||||
|
||||
void pauseQueue() {
|
||||
_queuePaused = true;
|
||||
_emit();
|
||||
}
|
||||
|
||||
void resumeQueue() {
|
||||
_queuePaused = false;
|
||||
_emit();
|
||||
_pumpQueue();
|
||||
}
|
||||
|
||||
Future<void> retry(String taskId) async {
|
||||
final existing = _tasks[taskId];
|
||||
if (existing == null) return;
|
||||
@@ -62,7 +76,7 @@ class DownloadManagerService {
|
||||
error: null,
|
||||
);
|
||||
_set(task);
|
||||
await _run(task);
|
||||
_pumpQueue();
|
||||
}
|
||||
|
||||
void cancel(String taskId) {
|
||||
@@ -71,8 +85,19 @@ class DownloadManagerService {
|
||||
_set(existing.copyWith(status: DownloadTaskStatus.canceled));
|
||||
}
|
||||
|
||||
void clearFinished() {
|
||||
_tasks.removeWhere(
|
||||
(_, task) =>
|
||||
task.status == DownloadTaskStatus.completed ||
|
||||
task.status == DownloadTaskStatus.failed ||
|
||||
task.status == DownloadTaskStatus.canceled,
|
||||
);
|
||||
_emit();
|
||||
}
|
||||
|
||||
Future<void> _run(DownloadTask task) async {
|
||||
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
|
||||
if (!_runningTaskIds.add(task.id)) return;
|
||||
_set(task.copyWith(status: DownloadTaskStatus.running));
|
||||
try {
|
||||
final saved = task.sourceUrl == null
|
||||
@@ -121,14 +146,30 @@ class DownloadManagerService {
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_runningTaskIds.remove(task.id);
|
||||
_pumpQueue();
|
||||
}
|
||||
}
|
||||
|
||||
void _set(DownloadTask task) {
|
||||
_tasks[task.id] = task;
|
||||
_emit();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
_controller.add(tasks);
|
||||
}
|
||||
|
||||
void _pumpQueue() {
|
||||
if (_queuePaused) return;
|
||||
for (final task in tasks) {
|
||||
if (task.status != DownloadTaskStatus.queued) continue;
|
||||
if (_runningTaskIds.contains(task.id)) continue;
|
||||
unawaited(_run(task));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateProgress(String taskId, int received, int total) {
|
||||
if (total <= 0) return;
|
||||
final current = _tasks[taskId];
|
||||
@@ -143,7 +184,7 @@ class DownloadManagerService {
|
||||
final current = _tasks[taskId];
|
||||
if (current?.status != DownloadTaskStatus.completed) return;
|
||||
_tasks.remove(taskId);
|
||||
_controller.add(tasks);
|
||||
_emit();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@ class DownloadService {
|
||||
if (Platform.isAndroid) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = p.join(tempDir.path, fileName);
|
||||
await _dio.download(url, tempPath, onReceiveProgress: onProgress);
|
||||
await _dio.download(
|
||||
url,
|
||||
tempPath,
|
||||
onReceiveProgress: onProgress,
|
||||
options: Options(headers: _headersFor(post)),
|
||||
);
|
||||
final saved = await _channel.invokeMethod<String>('saveToDownloads', {
|
||||
'path': tempPath,
|
||||
'fileName': fileName,
|
||||
@@ -39,7 +44,12 @@ class DownloadService {
|
||||
|
||||
final location = await getSaveLocation(suggestedName: fileName);
|
||||
if (location == null) return null;
|
||||
await _dio.download(url, location.path, onReceiveProgress: onProgress);
|
||||
await _dio.download(
|
||||
url,
|
||||
location.path,
|
||||
onReceiveProgress: onProgress,
|
||||
options: Options(headers: _headersFor(post)),
|
||||
);
|
||||
return location.path;
|
||||
}
|
||||
|
||||
@@ -120,4 +130,26 @@ class DownloadService {
|
||||
if (value.contains('.png') || value.contains('png')) return 'image/png';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
final lower = '${post.providerId} ${post.providerName} '
|
||||
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
|
||||
.toLowerCase();
|
||||
return {
|
||||
'User-Agent': lower.contains('realbooru') || lower.contains('paheal')
|
||||
? 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36'
|
||||
: 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': lower.contains('realbooru') || lower.contains('paheal')
|
||||
? 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8'
|
||||
: '*/*',
|
||||
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
|
||||
if (lower.contains('rule34') && !lower.contains('paheal'))
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
if (lower.contains('realbooru'))
|
||||
'Referer':
|
||||
'https://realbooru.com/index.php?page=post&s=view&id=${post.id}',
|
||||
if (lower.contains('paheal'))
|
||||
'Referer': 'https://rule34.paheal.net/post/view/${post.id}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,14 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
|
||||
.toList(growable: false);
|
||||
if (requested.isEmpty) return true;
|
||||
final postTags = _postTagSet(post);
|
||||
return requested.every(postTags.contains);
|
||||
return requested.every(
|
||||
(requestedTag) => postTags.any(
|
||||
(postTag) =>
|
||||
postTag == requestedTag ||
|
||||
postTag.startsWith('${requestedTag}_') ||
|
||||
postTag.startsWith('$requestedTag-'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool postPassesTagFilters(Post post, AppSettings settings) {
|
||||
|
||||
@@ -32,7 +32,7 @@ class SearchService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
|
||||
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
|
||||
return _repository.recent(limit: limit);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class AppSettings {
|
||||
required this.videoPlayerHalfVolume,
|
||||
required this.videoPlayerLoop,
|
||||
required this.videoPlayerCover,
|
||||
required this.videoPlaybackPositions,
|
||||
required this.hiddenPostKeys,
|
||||
required this.diagnosticLogLines,
|
||||
required this.lastFeedTags,
|
||||
@@ -82,6 +83,7 @@ class AppSettings {
|
||||
final bool videoPlayerHalfVolume;
|
||||
final bool videoPlayerLoop;
|
||||
final bool videoPlayerCover;
|
||||
final Map<String, int> videoPlaybackPositions;
|
||||
final List<String> hiddenPostKeys;
|
||||
final List<String> diagnosticLogLines;
|
||||
final List<String> lastFeedTags;
|
||||
@@ -108,7 +110,7 @@ class AppSettings {
|
||||
mobileColumns: 2,
|
||||
blurExplicitContent: true,
|
||||
allowDownloads: true,
|
||||
autoDownloadFavorites: false,
|
||||
autoDownloadFavorites: true,
|
||||
selectedFeedProviderIds: [],
|
||||
showPostBadges: true,
|
||||
defaultTopPeriodFilter: 'none',
|
||||
@@ -123,6 +125,7 @@ class AppSettings {
|
||||
videoPlayerHalfVolume: false,
|
||||
videoPlayerLoop: false,
|
||||
videoPlayerCover: false,
|
||||
videoPlaybackPositions: {},
|
||||
hiddenPostKeys: [],
|
||||
diagnosticLogLines: [],
|
||||
lastFeedTags: [],
|
||||
@@ -165,6 +168,7 @@ class AppSettings {
|
||||
bool? videoPlayerHalfVolume,
|
||||
bool? videoPlayerLoop,
|
||||
bool? videoPlayerCover,
|
||||
Map<String, int>? videoPlaybackPositions,
|
||||
List<String>? hiddenPostKeys,
|
||||
List<String>? diagnosticLogLines,
|
||||
List<String>? lastFeedTags,
|
||||
@@ -212,6 +216,8 @@ class AppSettings {
|
||||
videoPlayerHalfVolume ?? this.videoPlayerHalfVolume,
|
||||
videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop,
|
||||
videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover,
|
||||
videoPlaybackPositions:
|
||||
videoPlaybackPositions ?? this.videoPlaybackPositions,
|
||||
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
|
||||
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
|
||||
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
|
||||
@@ -256,6 +262,7 @@ class AppSettings {
|
||||
'videoPlayerHalfVolume': videoPlayerHalfVolume,
|
||||
'videoPlayerLoop': videoPlayerLoop,
|
||||
'videoPlayerCover': videoPlayerCover,
|
||||
'videoPlaybackPositions': videoPlaybackPositions,
|
||||
'hiddenPostKeys': hiddenPostKeys,
|
||||
'diagnosticLogLines': diagnosticLogLines,
|
||||
'lastFeedTags': lastFeedTags,
|
||||
@@ -334,6 +341,10 @@ class AppSettings {
|
||||
(json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop,
|
||||
videoPlayerCover:
|
||||
(json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover,
|
||||
videoPlaybackPositions: Map<String, int>.from(
|
||||
(json['videoPlaybackPositions'] as Map?) ??
|
||||
defaults.videoPlaybackPositions,
|
||||
),
|
||||
hiddenPostKeys: List<String>.from(
|
||||
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
|
||||
),
|
||||
@@ -469,6 +480,30 @@ class SettingsService {
|
||||
return updateSettings(settings.copyWith(hiddenPostKeys: const []));
|
||||
}
|
||||
|
||||
Future<Result<void>> saveVideoPlaybackPosition(
|
||||
String cacheKey,
|
||||
int milliseconds, {
|
||||
int maxEntries = 300,
|
||||
}) async {
|
||||
if (cacheKey.isEmpty || milliseconds < 1000) return const Success(null);
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
final existing = settings.videoPlaybackPositions[cacheKey] ?? 0;
|
||||
if ((existing - milliseconds).abs() < 1000) return const Success(null);
|
||||
final positions = <String, int>{
|
||||
...settings.videoPlaybackPositions,
|
||||
cacheKey: milliseconds,
|
||||
};
|
||||
if (positions.length > maxEntries) {
|
||||
final removeCount = positions.length - maxEntries;
|
||||
for (final key in positions.keys.take(removeCount).toList()) {
|
||||
positions.remove(key);
|
||||
}
|
||||
}
|
||||
return updateSettings(settings.copyWith(videoPlaybackPositions: positions));
|
||||
}
|
||||
|
||||
Future<Result<void>> appendDiagnosticLog(String message) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
|
||||
@@ -35,7 +35,38 @@ class ViewedHistoryService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<ViewedPostEntry>>> getViewedPostEntries() async {
|
||||
final viewedResult = await _repository.recent();
|
||||
if (viewedResult is Error<List<ViewedPost>>) {
|
||||
return Error(viewedResult.failure);
|
||||
}
|
||||
final viewed = (viewedResult as Success<List<ViewedPost>>).data;
|
||||
final postsResult = await _postRepository.getCachedPostsByKeys(
|
||||
viewed.map((item) => item.viewedKey),
|
||||
);
|
||||
if (postsResult is Error<List<Post>>) {
|
||||
return Error(postsResult.failure);
|
||||
}
|
||||
final postsByKey = {
|
||||
for (final post in (postsResult as Success<List<Post>>).data)
|
||||
post.cacheKey: post,
|
||||
};
|
||||
return Success([
|
||||
for (final item in viewed)
|
||||
if (postsByKey[item.viewedKey] != null)
|
||||
ViewedPostEntry(
|
||||
post: postsByKey[item.viewedKey]!, viewedAt: item.viewedAt),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() {
|
||||
return _repository.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class ViewedPostEntry {
|
||||
const ViewedPostEntry({required this.post, required this.viewedAt});
|
||||
|
||||
final Post post;
|
||||
final DateTime viewedAt;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user