Files
youtube_personal/lib/services/radar_service.dart
T

1617 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/radar_recommendation.dart';
import '../models/video_item.dart';
import 'hidden_channels_service.dart';
import 'library_service.dart';
import 'youtube_service.dart';
class RadarService {
RadarService._();
static final RadarService instance = RadarService._();
static const String _dismissedKey =
'youtube_personal.radar.dismissed.v1';
static const String _reducedAuthorsKey =
'youtube_personal.radar.reduced_authors.v1';
static const Duration _dismissDuration =
Duration(days: 30);
final SharedPreferencesAsync _preferences =
SharedPreferencesAsync();
final YoutubeService _youtube = YoutubeService();
final Map<String, _CachedRadar>
_cache = <String, _CachedRadar>{};
int _shuffleCounter = 0;
RadarFeed? _lastFeed;
RadarFeed? get lastFeed => _lastFeed;
Future<RadarFeed> generate({
required RadarSessionMode mode,
bool force = false,
String interest = 'Tous',
}) async {
final history =
LibraryService.instance.history.value;
final favorites =
LibraryService.instance.favorites.value;
if (history.isEmpty && favorites.isEmpty) {
final emptyFeed = RadarFeed(
mode: mode,
generatedAt: DateTime.now(),
profileTopics: const [],
primary: const [],
continueTopic: const [],
discovery: const [],
categories:
const <String, List<RadarRecommendation>>{},
);
_lastFeed = emptyFeed;
return emptyFeed;
}
await HiddenChannelsService.instance.ensureLoaded();
final dismissed =
await _readDismissed();
final reducedAuthors =
await _readReducedAuthors();
final fingerprint = _fingerprint(
history,
favorites,
dismissed,
reducedAuthors,
HiddenChannelsService.instance.revision.value,
);
final normalizedInterest =
_normalizeInterest(interest);
final cacheKey =
'${mode.name}|$normalizedInterest';
final cached = _cache[cacheKey];
if (!force &&
cached != null &&
cached.fingerprint == fingerprint) {
_lastFeed = cached.feed;
return cached.feed;
}
if (force) {
_shuffleCounter++;
}
final profile = _buildProfile(
history,
favorites,
);
final queries = _buildQueries(
profile,
mode,
interest: normalizedInterest,
);
final candidateById =
<String, VideoItem>{};
final results = await Future.wait(
queries.map(
(query) async {
try {
return await _youtube.searchVideos(query);
} catch (_) {
return const <VideoItem>[];
}
},
),
);
for (final list in results) {
for (final video in list) {
candidateById.putIfAbsent(
video.id,
() => video,
);
}
}
final seenVideoIds = <String>{
...history.map((video) => video.id),
...favorites.map((video) => video.id),
};
final now = DateTime.now();
final scoredCandidates =
candidateById.values
.where(
(video) =>
!seenVideoIds.contains(video.id) &&
!HiddenChannelsService.instance.isHiddenVideo(video) &&
!_isDismissed(
video.id,
dismissed,
now,
),
)
.map(
(video) => _score(
video: video,
profile: profile,
mode: mode,
reducedAuthors: reducedAuthors,
),
)
.where(
(item) => item.score >= 18,
)
.toList();
final matchingInterest =
normalizedInterest == 'Tous'
? scoredCandidates
: scoredCandidates
.where(
(item) =>
_classifyVideo(
item.video,
) ==
normalizedInterest,
)
.toList();
final candidates =
normalizedInterest != 'Tous' &&
matchingInterest.length >= 4
? matchingInterest
: scoredCandidates;
final random = Random(
DateTime.now().day +
mode.index * 7919 +
_shuffleCounter * 104729,
);
final jitterByVideo = <String, int>{
for (final item in candidates)
item.video.id: random.nextInt(5),
};
candidates.sort(
(a, b) {
final aJitter =
jitterByVideo[a.video.id] ?? 0;
final bJitter =
jitterByVideo[b.video.id] ?? 0;
return (b.score + bJitter)
.compareTo(a.score + aJitter);
},
);
final diversified =
_diversify(candidates);
final primary =
<RadarRecommendation>[];
final continueTopic =
<RadarRecommendation>[];
final discovery =
<RadarRecommendation>[];
final used = <String>{};
void takeInto(
List<RadarRecommendation> target,
Iterable<RadarRecommendation> source,
int limit,
) {
for (final item in source) {
if (target.length >= limit) {
return;
}
if (used.add(item.video.id)) {
target.add(item);
}
}
}
takeInto(
primary,
diversified,
8,
);
takeInto(
continueTopic,
diversified.where(
(item) =>
item.lane ==
RadarLane.continueTopic,
),
10,
);
takeInto(
discovery,
diversified.where(
(item) =>
item.lane ==
RadarLane.discovery,
),
10,
);
// Si un lane est trop petit, on le complète sans dupliquer
// les cartes déjà utilisées.
if (continueTopic.length < 4) {
takeInto(
continueTopic,
diversified.where(
(item) =>
item.matchedTerms.isNotEmpty &&
!item.isNewAuthor,
),
8,
);
}
if (discovery.length < 4) {
takeInto(
discovery,
diversified.where(
(item) => item.isNewAuthor,
),
8,
);
}
final categories =
_buildCategories(diversified);
final feed = RadarFeed(
mode: mode,
generatedAt: now,
profileTopics:
profile.topTerms.take(6).toList(
growable: false,
),
primary:
List.unmodifiable(primary),
continueTopic:
List.unmodifiable(continueTopic),
discovery:
List.unmodifiable(discovery),
categories: categories,
);
_lastFeed = feed;
_cache[cacheKey] = _CachedRadar(
fingerprint: fingerprint,
feed: feed,
);
return feed;
}
Future<void> dismissVideo(
String videoId,
) async {
final dismissed =
await _readDismissed();
dismissed[videoId] =
DateTime.now().millisecondsSinceEpoch;
await _preferences.setString(
_dismissedKey,
jsonEncode(dismissed),
);
_cache.clear();
}
Future<void> reduceAuthor(
String author,
) async {
final normalized =
_normalizeAuthor(author);
if (normalized.isEmpty) {
return;
}
final authors =
await _readReducedAuthors();
authors[normalized] =
(authors[normalized] ?? 0) + 1;
await _preferences.setString(
_reducedAuthorsKey,
jsonEncode(authors),
);
_cache.clear();
}
Future<void> resetFeedback() async {
await _preferences.remove(
_dismissedKey,
);
await _preferences.remove(
_reducedAuthorsKey,
);
_cache.clear();
}
_RadarProfile _buildProfile(
List<VideoItem> history,
List<VideoItem> favorites,
) {
final topicWeights =
<String, double>{};
final favoriteWeights =
<String, double>{};
final authorWeights =
<String, double>{};
void addTerms(
VideoItem video,
double weight, {
Map<String, double>? target,
}) {
final destination =
target ?? topicWeights;
for (final term in _terms(
video.title,
)) {
destination.update(
term,
(value) => value + weight,
ifAbsent: () => weight,
);
}
}
for (var index = 0;
index < history.length &&
index < 30;
index++) {
final recency =
max(0.25, 1.0 - index * 0.035);
addTerms(
history[index],
recency,
);
final author = _normalizeAuthor(
history[index].author,
);
if (author.isNotEmpty) {
authorWeights.update(
author,
(value) => value + recency,
ifAbsent: () => recency,
);
}
}
for (var index = 0;
index < favorites.length &&
index < 20;
index++) {
final weight =
max(0.7, 1.35 - index * 0.025);
addTerms(
favorites[index],
weight,
);
addTerms(
favorites[index],
weight,
target: favoriteWeights,
);
final author = _normalizeAuthor(
favorites[index].author,
);
if (author.isNotEmpty) {
authorWeights.update(
author,
(value) => value + weight * 1.35,
ifAbsent: () => weight * 1.35,
);
}
}
final sortedTerms =
topicWeights.entries.toList()
..sort(
(a, b) =>
b.value.compareTo(a.value),
);
final recent = history.isNotEmpty
? history.first
: favorites.first;
final recentTerms =
_terms(recent.title).toSet();
return _RadarProfile(
topicWeights: topicWeights,
favoriteWeights: favoriteWeights,
authorWeights: authorWeights,
knownAuthors:
authorWeights.keys.toSet(),
recentAuthor:
_normalizeAuthor(recent.author),
recentTerms: recentTerms,
topTerms: sortedTerms
.map((entry) => entry.key)
.toList(growable: false),
history: history,
favorites: favorites,
);
}
List<String> _buildQueries(
_RadarProfile profile,
RadarSessionMode mode, {
String interest = 'Tous',
}) {
final queries = <String>[];
String applyMode(
String query,
) {
switch (mode) {
case RadarSessionMode.learn:
return '$query tutorial explained';
case RadarSessionMode.discovery:
return '$query découverte nouvelles chaînes';
case RadarSessionMode.relax:
return '$query relaxing';
case RadarSessionMode.short:
return '$query court';
case RadarSessionMode.long:
return '$query long format';
case RadarSessionMode.normal:
return query;
}
}
if (interest != 'Tous') {
final interestQuery =
_interestSearchQueries[interest];
if (interestQuery != null &&
interestQuery.isNotEmpty) {
queries.add(
applyMode(interestQuery),
);
}
}
final seeds = <VideoItem>[
...profile.history.take(3),
...profile.favorites.take(2),
];
for (final video in seeds) {
final words = _terms(video.title)
.take(4)
.toList();
if (words.isEmpty) {
continue;
}
var query = words.join(' ');
if (mode ==
RadarSessionMode.discovery &&
profile.topTerms.length >= 2) {
query =
'${profile.topTerms.take(2).join(' ')} $query';
}
queries.add(
applyMode(query),
);
if (queries.length >= 4) {
break;
}
}
if (queries.length < 4 &&
profile.topTerms.isNotEmpty) {
queries.add(
applyMode(
profile.topTerms
.take(3)
.join(' '),
),
);
}
return queries
.where(
(query) => query.trim().isNotEmpty,
)
.toSet()
.take(4)
.toList(growable: false);
}
RadarRecommendation _score({
required VideoItem video,
required _RadarProfile profile,
required RadarSessionMode mode,
required Map<String, int>
reducedAuthors,
}) {
final terms =
_terms(video.title).toSet();
final matchedTerms =
terms
.where(
profile.topicWeights
.containsKey,
)
.toList()
..sort(
(a, b) =>
(profile.topicWeights[b] ?? 0)
.compareTo(
profile.topicWeights[a] ?? 0,
),
);
final topicRaw = matchedTerms.fold<double>(
0,
(sum, term) =>
sum +
(profile.topicWeights[term] ?? 0),
);
final maxTopicWeight =
profile.topicWeights.values.isEmpty
? 1.0
: profile.topicWeights.values
.reduce(max);
final topicPoints =
min(
35,
(topicRaw /
maxTopicWeight *
13)
.round(),
);
final author =
_normalizeAuthor(video.author);
final authorRaw =
profile.authorWeights[author] ?? 0;
final maxAuthorWeight =
profile.authorWeights.values.isEmpty
? 1.0
: profile.authorWeights.values
.reduce(max);
final authorPoints =
min(
20,
(authorRaw /
maxAuthorWeight *
20)
.round(),
);
final favoriteRaw = terms.fold<double>(
0,
(sum, term) =>
sum +
(profile.favoriteWeights[term] ??
0),
);
final maxFavoriteWeight =
profile.favoriteWeights.values.isEmpty
? 1.0
: profile.favoriteWeights.values
.reduce(max);
final favoritePoints =
min(
15,
(favoriteRaw /
maxFavoriteWeight *
10)
.round(),
);
final isNewAuthor =
author.isNotEmpty &&
!profile.knownAuthors.contains(author);
final discoveryPoints =
isNewAuthor ? 8 : 0;
final durationPoints =
_durationScore(
video.duration,
mode,
);
final modePoints =
_modeTextScore(
terms,
mode,
);
final currentTopicPoints =
terms
.intersection(
profile.recentTerms,
)
.isNotEmpty
? 8
: 0;
final reducedPenalty =
min(
30,
(reducedAuthors[author] ?? 0) *
12,
);
var total = 12 +
topicPoints +
authorPoints +
favoritePoints +
discoveryPoints +
durationPoints +
modePoints +
currentTopicPoints -
reducedPenalty;
if (mode ==
RadarSessionMode.discovery &&
isNewAuthor) {
total += 10;
}
total = total.clamp(1, 99).toInt();
final reasons = <String>[];
if (currentTopicPoints > 0) {
reasons.add(
'Proche de ce que tu regardes maintenant',
);
}
if (matchedTerms.isNotEmpty) {
reasons.add(
'Thèmes communs : '
'${matchedTerms.take(3).join(', ')}',
);
}
if (authorPoints >= 8) {
reasons.add(
'Chaîne proche de tes habitudes',
);
}
if (favoritePoints >= 5) {
reasons.add(
'Sujet proche de tes favoris',
);
}
if (isNewAuthor) {
reasons.add(
'Nouvelle chaîne pour toi',
);
}
if (durationPoints >= 7) {
reasons.add(
_durationReason(mode),
);
}
if (reducedPenalty > 0) {
reasons.add(
'Score réduit selon ton feedback précédent',
);
}
if (reasons.isEmpty) {
reasons.add(
'Proposition issue du mélange de ton Radar',
);
}
RadarLane lane =
RadarLane.forYou;
if (isNewAuthor &&
matchedTerms.isNotEmpty) {
lane = RadarLane.discovery;
} else if (currentTopicPoints > 0 ||
author ==
profile.recentAuthor) {
lane =
RadarLane.continueTopic;
}
return RadarRecommendation(
video: video,
score: total,
reasons:
List.unmodifiable(reasons),
breakdown: Map.unmodifiable(
<String, int>{
'Sujet': topicPoints,
'Habitudes': authorPoints,
'Favoris': favoritePoints,
'Découverte':
discoveryPoints,
'Session':
durationPoints +
modePoints +
currentTopicPoints,
if (reducedPenalty > 0)
'Feedback': -reducedPenalty,
},
),
lane: lane,
matchedTerms:
matchedTerms.take(5).toList(
growable: false,
),
isNewAuthor: isNewAuthor,
);
}
int _durationScore(
Duration? duration,
RadarSessionMode mode,
) {
if (duration == null) {
return 0;
}
final minutes =
duration.inMinutes;
switch (mode) {
case RadarSessionMode.short:
if (minutes <= 8) {
return 15;
}
if (minutes <= 15) {
return 9;
}
return -8;
case RadarSessionMode.long:
if (minutes >= 30) {
return 15;
}
if (minutes >= 20) {
return 9;
}
return -5;
case RadarSessionMode.relax:
if (minutes >= 8 &&
minutes <= 45) {
return 7;
}
return 2;
case RadarSessionMode.learn:
if (minutes >= 6 &&
minutes <= 60) {
return 5;
}
return 1;
case RadarSessionMode.discovery:
case RadarSessionMode.normal:
return 3;
}
}
int _modeTextScore(
Set<String> terms,
RadarSessionMode mode,
) {
switch (mode) {
case RadarSessionMode.learn:
const learningTerms = <String>{
'tutorial',
'guide',
'explained',
'learn',
'course',
'cours',
'tuto',
'شرح',
'تعلم',
};
return terms
.intersection(
learningTerms,
)
.isNotEmpty
? 12
: 0;
case RadarSessionMode.discovery:
return 2;
case RadarSessionMode.relax:
const relaxTerms = <String>{
'relax',
'relaxing',
'chill',
'calm',
'music',
'musique',
'ambient',
};
return terms
.intersection(
relaxTerms,
)
.isNotEmpty
? 10
: 0;
case RadarSessionMode.short:
case RadarSessionMode.long:
case RadarSessionMode.normal:
return 0;
}
}
String _durationReason(
RadarSessionMode mode,
) {
switch (mode) {
case RadarSessionMode.short:
return 'Durée adaptée au mode Courtes vidéos';
case RadarSessionMode.long:
return 'Long format adapté à cette session';
case RadarSessionMode.learn:
return 'Format adapté à une session d’apprentissage';
case RadarSessionMode.relax:
return 'Durée adaptée au mode Détente';
case RadarSessionMode.discovery:
case RadarSessionMode.normal:
return 'Durée adaptée à cette session';
}
}
Map<String, List<RadarRecommendation>>
_buildCategories(
List<RadarRecommendation> recommendations,
) {
final buckets =
<String, List<RadarRecommendation>>{};
// Use a larger candidate pool than the three headline lanes so category
// browsing feels like a real second way to explore the Radar.
for (final item
in recommendations.take(60)) {
final category =
_classifyVideo(item.video);
buckets.putIfAbsent(
category,
() => <RadarRecommendation>[],
);
final list = buckets[category]!;
if (list.length < 12) {
list.add(item);
}
}
final entries =
buckets.entries
.where(
(entry) =>
entry.value.isNotEmpty,
)
.toList()
..sort(
(a, b) {
final aScore = a.value.fold<int>(
0,
(sum, item) =>
sum + item.score,
);
final bScore = b.value.fold<int>(
0,
(sum, item) =>
sum + item.score,
);
final countCompare =
b.value.length.compareTo(
a.value.length,
);
if (countCompare != 0) {
return countCompare;
}
return bScore.compareTo(aScore);
},
);
return Map.unmodifiable(
<String, List<RadarRecommendation>>{
for (final entry in entries)
entry.key:
List.unmodifiable(entry.value),
},
);
}
String _classifyVideo(
VideoItem video,
) {
final haystack =
'${video.title} '
'${video.author} '
'${video.description}'
.toLowerCase();
var bestCategory = 'Divers';
var bestScore = 0;
for (final entry
in _categoryKeywords.entries) {
var score = 0;
for (final keyword in entry.value) {
if (haystack.contains(keyword)) {
// Longer phrases are more specific and receive a little more
// weight than generic single words.
score += keyword.contains(' ')
? 3
: 2;
}
}
if (score > bestScore) {
bestScore = score;
bestCategory = entry.key;
}
}
return bestCategory;
}
static const Map<String, List<String>>
_categoryKeywords =
<String, List<String>>{
'Jeux vidéo': <String>[
'jeu vidéo',
'jeux vidéo',
'gaming',
'gameplay',
'gamer',
'fortnite',
'minecraft',
'roblox',
'gta',
'call of duty',
'playstation',
'ps5',
'xbox',
'nintendo',
'switch',
'steam',
'pokemon',
'elden ring',
],
'Culture & histoire': <String>[
'culture',
'histoire',
'history',
'historique',
'documentaire',
'documentary',
'empire',
'civilisation',
'civilization',
'archéologie',
'archeology',
'mythologie',
'mythology',
'art ',
'littérature',
'literature',
'philosophie',
],
'OVNI & mystères': <String>[
'ovni',
'ufo',
'uap',
'alien',
'extraterrestre',
'extraterrestrial',
'paranormal',
'mystère',
'mystery',
'mysterious',
'inexpliqué',
'unexplained',
'roswell',
'area 51',
'zone 51',
'crop circle',
'conspiracy',
'complot',
],
'Technologie & IA': <String>[
'technologie',
'technology',
'tech ',
'intelligence artificielle',
'artificial intelligence',
'chatgpt',
'openai',
'android',
'windows',
'linux',
'xiaomi',
'samsung',
'iphone',
'apple',
'gpu',
'nvidia',
'amd',
'ordinateur',
'smartphone',
'robot',
],
'Science & espace': <String>[
'science',
'espace',
'space',
'nasa',
'spacex',
'astronomie',
'astronomy',
'physique',
'physics',
'quantique',
'quantum',
'mars ',
'lune',
'moon',
'univers',
'universe',
'telescope',
'télescope',
],
'Auto & transport': <String>[
'voiture',
'automobile',
'car ',
'cars ',
'tesla',
'bmw',
'mercedes',
'audi',
'porsche',
'moto',
'motorcycle',
'train',
'avion',
'airplane',
'aviation',
],
'Musique': <String>[
'music',
'musique',
'song',
'chanson',
'album',
'concert',
'live music',
'piano',
'guitar',
'guitare',
'rap ',
'hip hop',
'jazz',
],
'Politique': <String>[
'politique',
'politics',
'political',
'gouvernement',
'government',
'parlement',
'parliament',
'président',
'president',
'élection',
'election',
'élections',
'elections',
'géopolitique',
'geopolitics',
'diplomatie',
'diplomacy',
'ministre',
'minister',
'الانتخابات',
'سياسة',
'السياسة',
'حكومة',
'الحكومة',
],
'Finance': <String>[
'finance',
'financial',
'investissement',
'investment',
'investing',
'investisseur',
'investor',
'bourse',
'stock market',
'stocks',
'actions',
'etf',
'ipo',
'dividende',
'dividend',
'trading',
'trader',
'forex',
'crypto',
'bitcoin',
'ethereum',
'banque',
'banking',
'épargne',
'epargne',
'portefeuille',
'portfolio',
'nasdaq',
's&p 500',
'sp500',
'cac 40',
'masi',
'bvc',
'استثمار',
'الاستثمار',
'البورصة',
'الأسهم',
'تداول',
'التداول',
'بيتكوين',
],
'Société & actualité': <String>[
'actualité',
'actualités',
'news',
'société',
'society',
'économie',
'economy',
'reportage',
'interview',
'social',
'actualité internationale',
],
'Tutoriels & apprentissage': <String>[
'tutorial',
'tutoriel',
'tuto ',
'guide',
'cours',
'course',
'learn',
'apprendre',
'expliqué',
'explained',
'how to',
'comment ',
'formation',
],
};
String _normalizeInterest(
String value,
) {
final trimmed =
value.trim();
return _interestSearchQueries
.containsKey(trimmed)
? trimmed
: 'Tous';
}
static const Map<String, String>
_interestSearchQueries =
<String, String>{
'Tous': '',
'Politique':
'politique géopolitique actualité gouvernement élections',
'Finance':
'finance investissement bourse ETF marchés financiers',
'Technologie & IA':
'technologie intelligence artificielle informatique',
'Jeux vidéo':
'jeux vidéo gaming gameplay nouveautés',
'Culture & histoire':
'culture histoire documentaire civilisation',
'Science & espace':
'science espace astronomie univers',
'OVNI & mystères':
'ovni uap mystères inexpliqué',
'Auto & transport':
'automobile voiture transport technologie',
'Musique':
'musique artistes concert nouveautés',
'Société & actualité':
'société actualité reportage économie',
'Tutoriels & apprentissage':
'tutoriel apprendre guide formation',
};
List<RadarRecommendation> _diversify(
List<RadarRecommendation> source,
) {
final result =
<RadarRecommendation>[];
final authorCounts =
<String, int>{};
for (final item in source) {
final author =
_normalizeAuthor(
item.video.author,
);
final count =
authorCounts[author] ?? 0;
if (count >= 2) {
continue;
}
result.add(item);
if (author.isNotEmpty) {
authorCounts[author] =
count + 1;
}
}
return result;
}
List<String> _terms(String value) {
final normalized = value
.toLowerCase()
.replaceAll(
RegExp(
r"[^a-z0-9à-ÿ\u0600-\u06ff]+",
caseSensitive: false,
),
' ',
);
return normalized
.split(RegExp(r'\s+'))
.map((word) => word.trim())
.where(
(word) =>
word.length >= 3 &&
!_stopWords.contains(word),
)
.toList(growable: false);
}
String _normalizeAuthor(
String value,
) {
return value
.trim()
.toLowerCase();
}
Future<Map<String, int>>
_readReducedAuthors() async {
final raw =
await _preferences.getString(
_reducedAuthorsKey,
);
if (raw == null ||
raw.isEmpty) {
return <String, int>{};
}
try {
final decoded =
jsonDecode(raw);
if (decoded is! Map) {
return <String, int>{};
}
final result =
<String, int>{};
for (final entry
in decoded.entries) {
if (entry.key is String &&
entry.value is num) {
result[entry.key as String] =
(entry.value as num).toInt();
}
}
return result;
} catch (_) {
return <String, int>{};
}
}
Future<Map<String, int>>
_readDismissed() async {
final raw =
await _preferences.getString(
_dismissedKey,
);
if (raw == null ||
raw.isEmpty) {
return <String, int>{};
}
try {
final decoded =
jsonDecode(raw);
if (decoded is! Map) {
return <String, int>{};
}
final result =
<String, int>{};
for (final entry
in decoded.entries) {
if (entry.key is String &&
entry.value is num) {
result[entry.key as String] =
(entry.value as num).toInt();
}
}
final cutoff = DateTime.now()
.subtract(_dismissDuration)
.millisecondsSinceEpoch;
result.removeWhere(
(key, value) =>
value < cutoff,
);
return result;
} catch (_) {
return <String, int>{};
}
}
bool _isDismissed(
String videoId,
Map<String, int> dismissed,
DateTime now,
) {
final value =
dismissed[videoId];
if (value == null) {
return false;
}
final dismissedAt =
DateTime.fromMillisecondsSinceEpoch(
value,
);
return now.difference(dismissedAt) <=
_dismissDuration;
}
String _fingerprint(
List<VideoItem> history,
List<VideoItem> favorites,
Map<String, int> dismissed,
Map<String, int> reducedAuthors,
int hiddenRevision,
) {
return <String>[
...history
.take(20)
.map((video) => video.id),
'|',
...favorites
.take(20)
.map((video) => video.id),
'|',
...dismissed.keys,
'|',
...reducedAuthors.entries.map(
(entry) =>
'${entry.key}:${entry.value}',
),
'|hidden:$hiddenRevision',
].join(';');
}
static const Set<String> _stopWords =
<String>{
'the',
'and',
'for',
'with',
'from',
'this',
'that',
'you',
'your',
'are',
'how',
'why',
'what',
'into',
'une',
'des',
'les',
'pour',
'avec',
'dans',
'sur',
'est',
'que',
'qui',
'pas',
'plus',
'tout',
'tous',
'cette',
'ces',
'aux',
'du',
'de',
'la',
'le',
'un',
'en',
'der',
'die',
'das',
'und',
'mit',
'für',
'von',
'ist',
'ein',
'eine',
'wie',
'den',
'dem',
'auf',
'los',
'las',
'una',
'uno',
'con',
'para',
'por',
'como',
'del',
'este',
'esta',
'هذا',
'هذه',
'على',
'من',
'في',
'الى',
'إلى',
'كيف',
'مع',
'عن',
'كل',
'official',
'video',
'youtube',
};
}
class _RadarProfile {
const _RadarProfile({
required this.topicWeights,
required this.favoriteWeights,
required this.authorWeights,
required this.knownAuthors,
required this.recentAuthor,
required this.recentTerms,
required this.topTerms,
required this.history,
required this.favorites,
});
final Map<String, double> topicWeights;
final Map<String, double> favoriteWeights;
final Map<String, double> authorWeights;
final Set<String> knownAuthors;
final String recentAuthor;
final Set<String> recentTerms;
final List<String> topTerms;
final List<VideoItem> history;
final List<VideoItem> favorites;
}
class _CachedRadar {
const _CachedRadar({
required this.fingerprint,
required this.feed,
});
final String fingerprint;
final RadarFeed feed;
}