241 lines
6.2 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|