Files
youtube_personal/lib/services/channel_catalog_service.dart
T

241 lines
6.2 KiB
Dart

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<ChannelRankItem>? _cache;
String? _cacheFingerprint;
DateTime? _cacheAt;
Future<List<ChannelRankItem>> load({
bool force = false,
}) async {
await HiddenChannelsService.instance.ensureLoaded();
final history = LibraryService.instance.history.value;
if (history.isEmpty) {
return const [];
}
final fingerprint = <String>[
'${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 = <VideoItem>[];
// 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 = <String, _ChannelAccumulator>{};
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 = <ChannelRankItem>[];
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<ChannelRankItem>());
}
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;
}
}
}