import '../../core/cache/cache_service.dart'; import '../../core/utils/result.dart'; import '../models/post.dart'; import '../models/top_period_filter.dart'; import '../providers/provider_manager.dart'; import '../services/viewed_history_service.dart'; import '../utils/smart_blacklist.dart'; import 'settings_service.dart'; class FeedService { FeedService( this._providerManager, this._cacheService, this._settingsService, this._viewedHistoryService, ); final ProviderManager _providerManager; final CacheService _cacheService; final SettingsService _settingsService; final ViewedHistoryService _viewedHistoryService; final Map _pages = {}; String _key( List tags, String? rating, String? providerId, TopPeriodFilter topPeriod, ) { final sorted = [...tags]..sort(); return '${sorted.join(' ')}|${rating ?? ''}|${providerId ?? ''}|${topPeriod.name}'; } Future>> refresh({ List tags = const [], String? rating, String? providerId, TopPeriodFilter topPeriod = TopPeriodFilter.none, int limit = 50, }) { _pages[_key(tags, rating, providerId, topPeriod)] = 0; return loadNextPage( tags: tags, rating: rating, providerId: providerId, topPeriod: topPeriod, limit: limit, ); } Future>> loadNextPage({ List tags = const [], String? rating, String? providerId, TopPeriodFilter topPeriod = TopPeriodFilter.none, int limit = 50, }) async { final key = _key(tags, rating, providerId, topPeriod); final page = _pages[key] ?? 0; final result = await _providerManager.searchAcrossProviders( tags: tags, page: page, limit: limit, rating: rating, providerId: providerId, topPeriod: topPeriod, ); if (result is Error>) return Error(result.failure); final posts = (result as Success>).data; _pages[key] = page + 1; final settingsResult = await _settingsService.getSettings(); final settings = settingsResult is Success ? settingsResult.data : AppSettings.defaults; final viewedKeysResult = settings.hideViewedPosts ? await _viewedHistoryService.getViewedKeys() : const Success({}); final viewedKeys = viewedKeysResult is Success> ? viewedKeysResult.data : {}; final filteredPosts = _applyTopPeriod(posts, topPeriod) .where((post) => postMatchesRequestedTags(post, tags)) .where((post) => postPassesTagFilters(post, settings)) .where((post) => !settings.hideViewedPosts || !viewedKeys.contains(post.cacheKey)) .toList(growable: false); await _cacheService.cachePosts( filteredPosts, maxItems: settings.cacheMaxItems, ); return Success(filteredPosts); } List _applyTopPeriod(List posts, TopPeriodFilter period) { if (period == TopPeriodFilter.none) return posts; final sorted = [...posts]..sort((a, b) => b.score.compareTo(a.score)); if (period == TopPeriodFilter.allTime) return sorted; final now = DateTime.now(); final minDate = period == TopPeriodFilter.month ? now.subtract(const Duration(days: 31)) : DateTime(now.year - 1, now.month, now.day); final inPeriod = sorted .where((post) => post.createdAt.isAfter(minDate) && post.createdAt.isBefore(now.add(const Duration(days: 1)))) .toList(growable: false); return inPeriod.isEmpty ? sorted : inPeriod; } } bool postMatchesRequestedTags(Post post, List requestedTags) { if (post.providerId == 'pawchive') return true; final groups = ProviderManager.splitTagGroups(requestedTags); if (groups.isEmpty) return true; final postTags = _postTagSet(post); if (postTags.isEmpty) return true; return groups.any((group) { final cleanGroup = group .map((tag) => tag.trim().toLowerCase()) .where((tag) => tag.isNotEmpty && !tag.startsWith('-') && tag != 'and') .toList(growable: false); if (cleanGroup.isEmpty) return true; return cleanGroup.every( (requestedTag) => postTags.any( (postTag) => postTag == requestedTag || postTag.startsWith('${requestedTag}_') || postTag.startsWith('$requestedTag-'), ), ); }); } bool postPassesTagFilters(Post post, AppSettings settings) { if (settings.hiddenPostKeys.contains(post.cacheKey)) return false; final tags = _postTagSet(post); if (tags.isEmpty) return settings.whitelistedTags.isEmpty; final blacklist = [ ...settings.smartBlacklistRules, ...settings.blacklistedTags, ].map((tag) => tag.trim().toLowerCase()).where((tag) => tag.isNotEmpty); if (blacklist.any((rule) => SmartBlacklistMatcher.matches(post, rule))) { return false; } final whitelist = settings.whitelistedTags .map((tag) => tag.trim().toLowerCase()) .where((tag) => tag.isNotEmpty) .toList(growable: false); if (whitelist.isEmpty) return true; return whitelist.any(tags.contains); } Set _postTagSet(Post post) { return { ...post.tags, for (final group in post.tagGroups.values) ...group, } .map((tag) => tag.trim().toLowerCase()) .where((tag) => tag.isNotEmpty) .toSet(); }