import '../models/channel_rank_item.dart'; import '../models/subscription_channel.dart'; import '../models/video_item.dart'; import 'hidden_channels_service.dart'; import 'library_service.dart'; import 'youtube_service.dart'; class ChannelCatalogService { ChannelCatalogService._(); static final ChannelCatalogService instance = ChannelCatalogService._(); final YoutubeService _youtube = YoutubeService(); List? _cache; String? _cacheFingerprint; DateTime? _cacheAt; Future> load({ bool force = false, }) async { await HiddenChannelsService.instance.ensureLoaded(); final history = LibraryService.instance.history.value; if (history.isEmpty) { return const []; } final fingerprint = [ '${HiddenChannelsService.instance.revision.value}', for (final video in history.take(100)) '${video.id}:${LibraryService.instance.watchCountForVideo(video.id)}', ].join('|'); final cacheFresh = _cacheAt != null && DateTime.now().difference(_cacheAt!) < const Duration(minutes: 10); if (!force && cacheFresh && _cache != null && _cacheFingerprint == fingerprint) { return _cache!; } final enriched = []; // History is unique by video ID, but personal repeat counts are stored // separately. Resolve old entries created before channelId existed. for (var start = 0; start < history.length; start += 6) { final end = start + 6 < history.length ? start + 6 : history.length; final chunk = history.sublist(start, end); final resolved = await Future.wait( chunk.map( (video) async { if (video.channelId != null && video.channelId!.isNotEmpty) { return video; } try { return await _youtube.getVideoDetails(video.id); } catch (_) { return video; } }, ), ); enriched.addAll(resolved); } final groups = {}; for (var index = 0; index < enriched.length; index++) { final video = enriched[index]; var channelId = video.channelId?.trim() ?? ''; SubscriptionChannel? resolvedChannel; if (channelId.isEmpty) { try { resolvedChannel = await _youtube.getChannelForVideo(video.id); channelId = resolvedChannel.channelId; } catch (_) { continue; } } if (HiddenChannelsService.instance.isHiddenChannel( channelId: channelId, title: resolvedChannel?.title ?? video.author, )) { continue; } final personalViews = LibraryService.instance.watchCountForVideo(video.id); final safePersonalViews = personalViews > 0 ? personalViews : 1; groups .putIfAbsent( channelId, () => _ChannelAccumulator( channelId: channelId, fallbackTitle: resolvedChannel?.title ?? video.author, ), ) .add( video, personalViews: safePersonalViews, historyIndex: index, channelOverride: resolvedChannel, ); } final accumulators = groups.values.toList(); final result = []; for (var start = 0; start < accumulators.length; start += 6) { final end = start + 6 < accumulators.length ? start + 6 : accumulators.length; final chunk = accumulators.sublist(start, end); final resolved = await Future.wait( chunk.map( (group) async { SubscriptionChannel channel; if (group.channelOverride != null) { channel = group.channelOverride!; } else { try { channel = await _youtube.getChannel(group.channelId); } catch (_) { channel = SubscriptionChannel( channelId: group.channelId, title: group.fallbackTitle, thumbnailUrl: '', ); } } if (HiddenChannelsService.instance.isHiddenChannel( channelId: channel.channelId, title: channel.title, )) { return null; } return ChannelRankItem( channel: channel, personalViewCount: group.personalViewCount, distinctVideoCount: group.distinctVideoCount, topVideoPersonalViews: group.topVideoPersonalViews, topVideoTitle: group.topVideoTitle, ); }, ), ); result.addAll(resolved.whereType()); } result.sort( (a, b) { final views = b.personalViewCount.compareTo(a.personalViewCount); if (views != 0) { return views; } final distinct = b.distinctVideoCount.compareTo(a.distinctVideoCount); if (distinct != 0) { return distinct; } return a.channel.title .toLowerCase() .compareTo(b.channel.title.toLowerCase()); }, ); _cache = List.unmodifiable(result); _cacheFingerprint = fingerprint; _cacheAt = DateTime.now(); return _cache!; } } class _ChannelAccumulator { _ChannelAccumulator({ required this.channelId, required this.fallbackTitle, }); final String channelId; final String fallbackTitle; SubscriptionChannel? channelOverride; int personalViewCount = 0; int distinctVideoCount = 0; int topVideoPersonalViews = 0; int newestHistoryIndex = 1 << 30; String? topVideoTitle; void add( VideoItem video, { required int personalViews, required int historyIndex, SubscriptionChannel? channelOverride, }) { if (channelOverride != null) { this.channelOverride = channelOverride; } personalViewCount += personalViews; distinctVideoCount++; if (historyIndex < newestHistoryIndex) { newestHistoryIndex = historyIndex; } if (personalViews >= topVideoPersonalViews) { topVideoPersonalViews = personalViews; topVideoTitle = video.title; } } }