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 _cache = {}; int _shuffleCounter = 0; RadarFeed? _lastFeed; RadarFeed? get lastFeed => _lastFeed; Future 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 >{}, ); _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 = {}; final results = await Future.wait( queries.map( (query) async { try { return await _youtube.searchVideos(query); } catch (_) { return const []; } }, ), ); for (final list in results) { for (final video in list) { candidateById.putIfAbsent( video.id, () => video, ); } } final seenVideoIds = { ...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 = { 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 = []; final continueTopic = []; final discovery = []; final used = {}; void takeInto( List target, Iterable 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 dismissVideo( String videoId, ) async { final dismissed = await _readDismissed(); dismissed[videoId] = DateTime.now().millisecondsSinceEpoch; await _preferences.setString( _dismissedKey, jsonEncode(dismissed), ); _cache.clear(); } Future 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 resetFeedback() async { await _preferences.remove( _dismissedKey, ); await _preferences.remove( _reducedAuthorsKey, ); _cache.clear(); } _RadarProfile _buildProfile( List history, List favorites, ) { final topicWeights = {}; final favoriteWeights = {}; final authorWeights = {}; void addTerms( VideoItem video, double weight, { Map? 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 _buildQueries( _RadarProfile profile, RadarSessionMode mode, { String interest = 'Tous', }) { final queries = []; 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 = [ ...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 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( 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( 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 = []; 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( { '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 terms, RadarSessionMode mode, ) { switch (mode) { case RadarSessionMode.learn: const learningTerms = { 'tutorial', 'guide', 'explained', 'learn', 'course', 'cours', 'tuto', 'شرح', 'تعلم', }; return terms .intersection( learningTerms, ) .isNotEmpty ? 12 : 0; case RadarSessionMode.discovery: return 2; case RadarSessionMode.relax: const relaxTerms = { '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> _buildCategories( List recommendations, ) { final buckets = >{}; // 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, () => [], ); 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( 0, (sum, item) => sum + item.score, ); final bScore = b.value.fold( 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( >{ 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> _categoryKeywords = >{ 'Jeux vidéo': [ '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': [ 'culture', 'histoire', 'history', 'historique', 'documentaire', 'documentary', 'empire', 'civilisation', 'civilization', 'archéologie', 'archeology', 'mythologie', 'mythology', 'art ', 'littérature', 'literature', 'philosophie', ], 'OVNI & mystères': [ 'ovni', 'ufo', 'uap', 'alien', 'extraterrestre', 'extraterrestrial', 'paranormal', 'mystère', 'mystery', 'mysterious', 'inexpliqué', 'unexplained', 'roswell', 'area 51', 'zone 51', 'crop circle', 'conspiracy', 'complot', ], 'Technologie & IA': [ 'technologie', 'technology', 'tech ', 'intelligence artificielle', 'artificial intelligence', 'chatgpt', 'openai', 'android', 'windows', 'linux', 'xiaomi', 'samsung', 'iphone', 'apple', 'gpu', 'nvidia', 'amd', 'ordinateur', 'smartphone', 'robot', ], 'Science & espace': [ 'science', 'espace', 'space', 'nasa', 'spacex', 'astronomie', 'astronomy', 'physique', 'physics', 'quantique', 'quantum', 'mars ', 'lune', 'moon', 'univers', 'universe', 'telescope', 'télescope', ], 'Auto & transport': [ 'voiture', 'automobile', 'car ', 'cars ', 'tesla', 'bmw', 'mercedes', 'audi', 'porsche', 'moto', 'motorcycle', 'train', 'avion', 'airplane', 'aviation', ], 'Musique': [ 'music', 'musique', 'song', 'chanson', 'album', 'concert', 'live music', 'piano', 'guitar', 'guitare', 'rap ', 'hip hop', 'jazz', ], 'Politique': [ 'politique', 'politics', 'political', 'gouvernement', 'government', 'parlement', 'parliament', 'président', 'president', 'élection', 'election', 'élections', 'elections', 'géopolitique', 'geopolitics', 'diplomatie', 'diplomacy', 'ministre', 'minister', 'الانتخابات', 'سياسة', 'السياسة', 'حكومة', 'الحكومة', ], 'Finance': [ '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é': [ 'actualité', 'actualités', 'news', 'société', 'society', 'économie', 'economy', 'reportage', 'interview', 'social', 'actualité internationale', ], 'Tutoriels & apprentissage': [ '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 _interestSearchQueries = { '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 _diversify( List source, ) { final result = []; final authorCounts = {}; 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 _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> _readReducedAuthors() async { final raw = await _preferences.getString( _reducedAuthorsKey, ); if (raw == null || raw.isEmpty) { return {}; } try { final decoded = jsonDecode(raw); if (decoded is! Map) { return {}; } final result = {}; 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 {}; } } Future> _readDismissed() async { final raw = await _preferences.getString( _dismissedKey, ); if (raw == null || raw.isEmpty) { return {}; } try { final decoded = jsonDecode(raw); if (decoded is! Map) { return {}; } final result = {}; 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 {}; } } bool _isDismissed( String videoId, Map 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 history, List favorites, Map dismissed, Map reducedAuthors, int hiddenRevision, ) { return [ ...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 _stopWords = { '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 topicWeights; final Map favoriteWeights; final Map authorWeights; final Set knownAuthors; final String recentAuthor; final Set recentTerms; final List topTerms; final List history; final List favorites; } class _CachedRadar { const _CachedRadar({ required this.fingerprint, required this.feed, }); final String fingerprint; final RadarFeed feed; }