1043 lines
32 KiB
Dart
1043 lines
32 KiB
Dart
import 'dart:convert';
|
||
import 'dart:math';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import '../models/video_item.dart';
|
||
|
||
class InterestLearningScore {
|
||
const InterestLearningScore({
|
||
required this.points,
|
||
required this.positivePoints,
|
||
required this.negativePoints,
|
||
required this.channelPoints,
|
||
required this.reasons,
|
||
});
|
||
|
||
final int points;
|
||
final int positivePoints;
|
||
final int negativePoints;
|
||
final int channelPoints;
|
||
final List<String> reasons;
|
||
}
|
||
|
||
class InterestFeedbackEntry {
|
||
const InterestFeedbackEntry({
|
||
required this.video,
|
||
required this.category,
|
||
required this.rating,
|
||
required this.badQuality,
|
||
required this.notInterested,
|
||
required this.updatedAt,
|
||
});
|
||
|
||
final VideoItem video;
|
||
final String category;
|
||
final int rating;
|
||
final bool badQuality;
|
||
final bool notInterested;
|
||
final int updatedAt;
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return <String, dynamic>{
|
||
'video': video.toJson(),
|
||
'category': category,
|
||
'rating': rating,
|
||
'badQuality': badQuality,
|
||
'notInterested': notInterested,
|
||
'updatedAt': updatedAt,
|
||
};
|
||
}
|
||
|
||
factory InterestFeedbackEntry.fromJson(Map<String, dynamic> json) {
|
||
final rawVideo = json['video'];
|
||
if (rawVideo is! Map) {
|
||
throw const FormatException('video absent');
|
||
}
|
||
|
||
return InterestFeedbackEntry(
|
||
video: VideoItem.fromJson(
|
||
rawVideo.map(
|
||
(key, value) => MapEntry('$key', value),
|
||
),
|
||
),
|
||
category: json['category'] is String
|
||
? json['category'] as String
|
||
: InterestProfileService.allCategory,
|
||
rating: json['rating'] is num
|
||
? (json['rating'] as num).toInt().clamp(0, 5).toInt()
|
||
: 0,
|
||
badQuality: json['badQuality'] == true,
|
||
notInterested: json['notInterested'] == true,
|
||
updatedAt: json['updatedAt'] is num
|
||
? (json['updatedAt'] as num).toInt()
|
||
: 0,
|
||
);
|
||
}
|
||
}
|
||
|
||
class InterestProfileService {
|
||
InterestProfileService._();
|
||
|
||
static final InterestProfileService instance =
|
||
InterestProfileService._();
|
||
|
||
static const String allCategory = 'Tous';
|
||
|
||
static const List<String> categories = <String>[
|
||
allCategory,
|
||
'Politique',
|
||
'Jeux vidéo',
|
||
'Documentaires',
|
||
'Paranormal',
|
||
'Islam',
|
||
'Football',
|
||
'High-tech',
|
||
];
|
||
|
||
static const String _entriesKey =
|
||
'youtube_personal.interest_feedback.v1';
|
||
static const String _activeCategoryKey =
|
||
'youtube_personal.interest_active_category.v1';
|
||
static const String _videoCategoryOverridesKey =
|
||
'youtube_personal.interest_video_category_overrides.v1';
|
||
static const String _channelCategoryOverridesKey =
|
||
'youtube_personal.interest_channel_category_overrides.v1';
|
||
|
||
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
|
||
|
||
final ValueNotifier<String> activeCategory =
|
||
ValueNotifier<String>(allCategory);
|
||
final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||
|
||
final Map<String, Map<String, InterestFeedbackEntry>> _entries =
|
||
<String, Map<String, InterestFeedbackEntry>>{};
|
||
|
||
// 18.7.25 : l'utilisateur peut corriger le classificateur.
|
||
// Priorité : vidéo > chaîne > détection automatique.
|
||
final Map<String, String> _videoCategoryOverrides = <String, String>{};
|
||
final Map<String, String> _channelCategoryOverrides = <String, String>{};
|
||
|
||
bool _loaded = false;
|
||
Future<void>? _loading;
|
||
|
||
Future<void> ensureLoaded() {
|
||
if (_loaded) {
|
||
return Future<void>.value();
|
||
}
|
||
|
||
final current = _loading;
|
||
if (current != null) {
|
||
return current;
|
||
}
|
||
|
||
final future = _load();
|
||
_loading = future;
|
||
return future;
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
try {
|
||
final savedCategory =
|
||
await _preferences.getString(_activeCategoryKey);
|
||
if (savedCategory == 'Horreur') {
|
||
activeCategory.value = 'Paranormal';
|
||
await _preferences.setString(_activeCategoryKey, 'Paranormal');
|
||
} else if (savedCategory != null && categories.contains(savedCategory)) {
|
||
activeCategory.value = savedCategory;
|
||
}
|
||
|
||
await _loadCategoryOverrides();
|
||
|
||
final raw = await _preferences.getString(_entriesKey);
|
||
if (raw != null && raw.trim().isNotEmpty) {
|
||
final decoded = jsonDecode(raw);
|
||
if (decoded is Map) {
|
||
for (final categoryEntry in decoded.entries) {
|
||
final category = '${categoryEntry.key}';
|
||
final rawItems = categoryEntry.value;
|
||
if (rawItems is! Map) {
|
||
continue;
|
||
}
|
||
|
||
final bucket = <String, InterestFeedbackEntry>{};
|
||
for (final item in rawItems.entries) {
|
||
final value = item.value;
|
||
if (value is! Map) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
final parsed = InterestFeedbackEntry.fromJson(
|
||
value.map(
|
||
(key, value) => MapEntry('$key', value),
|
||
),
|
||
);
|
||
bucket[parsed.video.id] = parsed;
|
||
} catch (_) {
|
||
// Ignore a corrupted historical feedback entry.
|
||
}
|
||
}
|
||
|
||
if (bucket.isNotEmpty) {
|
||
_entries[category] = bucket;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 18.7.23d : renommage de la catégorie Horreur -> Paranormal.
|
||
// Toutes les notes et feedbacks existants sont conservés.
|
||
final paranormalMigrated = _migrateLegacyHorrorFeedback();
|
||
|
||
// 18.7.23a : les anciens feedbacks saisis depuis le profil `Tous`
|
||
// pouvaient être rangés dans le bucket technique `Autres` lorsque le
|
||
// classificateur ne connaissait pas encore une marque ou un terme.
|
||
// On ne migre QUE `Autres` : les buckets sélectionnables représentent
|
||
// potentiellement un choix explicite de l'utilisateur et ne doivent pas
|
||
// être reclassés automatiquement.
|
||
final migrated = _migrateLegacyOtherFeedback();
|
||
if (paranormalMigrated > 0 || migrated > 0) {
|
||
await _persistLoadedEntries();
|
||
}
|
||
if (paranormalMigrated > 0) {
|
||
debugPrint(
|
||
'[INTEREST][18.7.23d] $paranormalMigrated feedback(s) Horreur migré(s) vers Paranormal',
|
||
);
|
||
}
|
||
if (migrated > 0) {
|
||
debugPrint(
|
||
'[INTEREST][18.7.23a] $migrated feedback(s) Autres reclassé(s)',
|
||
);
|
||
}
|
||
} catch (error) {
|
||
debugPrint('[INTEREST][18.7.23] chargement ignoré: $error');
|
||
} finally {
|
||
_loaded = true;
|
||
_loading = null;
|
||
}
|
||
}
|
||
|
||
Future<void> setActiveCategory(String category) async {
|
||
await ensureLoaded();
|
||
final safe = categories.contains(category) ? category : allCategory;
|
||
if (activeCategory.value == safe) {
|
||
return;
|
||
}
|
||
|
||
activeCategory.value = safe;
|
||
await _preferences.setString(_activeCategoryKey, safe);
|
||
}
|
||
|
||
List<String> get assignableCategories => categories
|
||
.where((category) => category != allCategory)
|
||
.toList(growable: false);
|
||
|
||
String? videoCategoryOverride(VideoItem video) {
|
||
final value = _videoCategoryOverrides[video.id];
|
||
return value != null && categories.contains(value) && value != allCategory
|
||
? value
|
||
: null;
|
||
}
|
||
|
||
String? channelCategoryOverride(VideoItem video) {
|
||
for (final key in _channelLookupKeys(video)) {
|
||
final value = _channelCategoryOverrides[key];
|
||
if (value != null && categories.contains(value) && value != allCategory) {
|
||
return value;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? manualCategoryFor(VideoItem video) {
|
||
return videoCategoryOverride(video) ?? channelCategoryOverride(video);
|
||
}
|
||
|
||
Future<void> setVideoCategoryOverride(
|
||
VideoItem video,
|
||
String? category,
|
||
) async {
|
||
await ensureLoaded();
|
||
final safe = _safeAssignableCategory(category);
|
||
|
||
if (safe == null) {
|
||
_videoCategoryOverrides.remove(video.id);
|
||
} else {
|
||
_videoCategoryOverrides[video.id] = safe;
|
||
}
|
||
|
||
final target = manualCategoryFor(video) ?? classifyVideoAutomatically(video);
|
||
_moveFeedbackForVideoToCategory(video, target);
|
||
await _persistCategoryOverrides();
|
||
await _persistLoadedEntries();
|
||
revision.value++;
|
||
|
||
debugPrint(
|
||
'[INTEREST][18.7.25] vidéo ${video.id} -> ${safe ?? 'Auto ($target)'}',
|
||
);
|
||
}
|
||
|
||
Future<void> setChannelCategoryOverride(
|
||
VideoItem video,
|
||
String? category,
|
||
) async {
|
||
await ensureLoaded();
|
||
final safe = _safeAssignableCategory(category);
|
||
final keys = _channelLookupKeys(video);
|
||
|
||
for (final key in keys) {
|
||
if (safe == null) {
|
||
_channelCategoryOverrides.remove(key);
|
||
} else {
|
||
_channelCategoryOverrides[key] = safe;
|
||
}
|
||
}
|
||
|
||
_rehomeFeedbackForChannel(video);
|
||
await _persistCategoryOverrides();
|
||
await _persistLoadedEntries();
|
||
revision.value++;
|
||
|
||
debugPrint(
|
||
'[INTEREST][18.7.25] chaîne ${video.author} -> ${safe ?? 'Auto'}',
|
||
);
|
||
}
|
||
|
||
String effectiveCategoryFor(VideoItem video, {String? requested}) {
|
||
final selected = requested ?? activeCategory.value;
|
||
if (selected != allCategory && categories.contains(selected)) {
|
||
return selected;
|
||
}
|
||
return classifyVideo(video);
|
||
}
|
||
|
||
/// Catégorie réellement utilisée par le moteur. Une correction manuelle
|
||
/// est toujours prioritaire sur l'analyse automatique.
|
||
String classifyVideo(VideoItem video) {
|
||
return manualCategoryFor(video) ?? classifyVideoAutomatically(video);
|
||
}
|
||
|
||
/// Classification automatique pure, utile dans l'interface pour montrer
|
||
/// ce que le moteur aurait choisi sans correction de l'utilisateur.
|
||
String classifyVideoAutomatically(VideoItem video) {
|
||
final haystack = _normalize(
|
||
'${video.title} ${video.author} ${video.description}',
|
||
);
|
||
|
||
String best = 'Autres';
|
||
int bestScore = 0;
|
||
|
||
for (final entry in _categoryKeywords.entries) {
|
||
int score = 0;
|
||
for (final keyword in entry.value) {
|
||
final normalizedKeyword = _normalize(keyword).trim();
|
||
if (normalizedKeyword.isEmpty) {
|
||
continue;
|
||
}
|
||
if (_containsWholeKeyword(haystack, normalizedKeyword)) {
|
||
score += normalizedKeyword.contains(' ') ? 4 : 2;
|
||
}
|
||
}
|
||
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = entry.key;
|
||
}
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
bool matchesCategory(VideoItem video, String category) {
|
||
if (category == allCategory) {
|
||
return true;
|
||
}
|
||
|
||
// Une note donnée explicitement dans un profil devient une association
|
||
// manuelle plus fiable que la classification automatique du titre.
|
||
if (_entries[category]?.containsKey(video.id) ?? false) {
|
||
return true;
|
||
}
|
||
|
||
return classifyVideo(video) == category;
|
||
}
|
||
|
||
String searchHint(String category) {
|
||
switch (category) {
|
||
case 'Politique':
|
||
return 'politique actualité géopolitique';
|
||
case 'Jeux vidéo':
|
||
return 'jeux vidéo gaming gameplay';
|
||
case 'Documentaires':
|
||
return 'documentaire reportage enquête';
|
||
case 'Paranormal':
|
||
return 'paranormal surnaturel horreur haunted ghost scary';
|
||
case 'Islam':
|
||
return 'islam musulman coran';
|
||
case 'Football':
|
||
return 'football soccer';
|
||
case 'High-tech':
|
||
return 'high tech technologie intelligence artificielle';
|
||
case allCategory:
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
VideoItem categorySeed(String category) {
|
||
return VideoItem(
|
||
id: '_interest_seed_${category.hashCode}',
|
||
title: searchHint(category),
|
||
author: '',
|
||
thumbnailUrl: '',
|
||
description: '',
|
||
);
|
||
}
|
||
|
||
int ratingFor(VideoItem video, {String? category}) {
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
return _entries[effective]?[video.id]?.rating ?? 0;
|
||
}
|
||
|
||
bool isBadQuality(VideoItem video, {String? category}) {
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
return _entries[effective]?[video.id]?.badQuality ?? false;
|
||
}
|
||
|
||
bool isNotInterested(VideoItem video, {String? category}) {
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
return _entries[effective]?[video.id]?.notInterested ?? false;
|
||
}
|
||
|
||
bool shouldSuppressExact(VideoItem video, {String? category}) {
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
final entry = _entries[effective]?[video.id];
|
||
if (entry == null) {
|
||
return false;
|
||
}
|
||
return entry.notInterested || entry.badQuality ||
|
||
(entry.rating > 0 && entry.rating <= 2);
|
||
}
|
||
|
||
Future<void> rateVideo(
|
||
VideoItem video,
|
||
int rating, {
|
||
String? category,
|
||
}) async {
|
||
await ensureLoaded();
|
||
final safeRating = rating.clamp(1, 5).toInt();
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
final previous = _entries[effective]?[video.id];
|
||
|
||
_put(
|
||
InterestFeedbackEntry(
|
||
video: video,
|
||
category: effective,
|
||
rating: safeRating,
|
||
badQuality: safeRating <= 2,
|
||
notInterested:
|
||
safeRating >= 4 ? false : (previous?.notInterested ?? false),
|
||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||
),
|
||
);
|
||
|
||
await _persist();
|
||
}
|
||
|
||
Future<void> markBadQuality(
|
||
VideoItem video, {
|
||
String? category,
|
||
}) async {
|
||
await ensureLoaded();
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
final previous = _entries[effective]?[video.id];
|
||
|
||
_put(
|
||
InterestFeedbackEntry(
|
||
video: video,
|
||
category: effective,
|
||
rating: previous == null || previous.rating == 0
|
||
? 1
|
||
: min(previous.rating, 2),
|
||
badQuality: true,
|
||
notInterested: previous?.notInterested ?? false,
|
||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||
),
|
||
);
|
||
|
||
await _persist();
|
||
}
|
||
|
||
Future<void> markNotInterested(
|
||
VideoItem video, {
|
||
String? category,
|
||
}) async {
|
||
await ensureLoaded();
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
final previous = _entries[effective]?[video.id];
|
||
|
||
_put(
|
||
InterestFeedbackEntry(
|
||
video: video,
|
||
category: effective,
|
||
rating: previous?.rating ?? 0,
|
||
badQuality: previous?.badQuality ?? false,
|
||
notInterested: true,
|
||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||
),
|
||
);
|
||
|
||
await _persist();
|
||
}
|
||
|
||
Future<void> clearVideoFeedback(
|
||
VideoItem video, {
|
||
String? category,
|
||
}) async {
|
||
await ensureLoaded();
|
||
final effective = effectiveCategoryFor(video, requested: category);
|
||
_entries[effective]?.remove(video.id);
|
||
if (_entries[effective]?.isEmpty ?? false) {
|
||
_entries.remove(effective);
|
||
}
|
||
await _persist();
|
||
}
|
||
|
||
Future<void> resetAll() async {
|
||
await ensureLoaded();
|
||
_entries.clear();
|
||
await _preferences.remove(_entriesKey);
|
||
revision.value++;
|
||
}
|
||
|
||
List<VideoItem> positiveExamples(String category) {
|
||
final entries = _entriesForScoringCategory(category);
|
||
final sorted = entries
|
||
.where((entry) => entry.rating >= 4 && !entry.notInterested)
|
||
.toList()
|
||
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||
|
||
return sorted
|
||
.take(20)
|
||
.map((entry) => entry.video)
|
||
.toList(growable: false);
|
||
}
|
||
|
||
int feedbackCount(String category) {
|
||
return _entriesForScoringCategory(category).length;
|
||
}
|
||
|
||
InterestLearningScore scoreVideo(VideoItem video, String category) {
|
||
final effective = category == allCategory ? classifyVideo(video) : category;
|
||
final examples = _entries[effective]?.values.toList() ??
|
||
const <InterestFeedbackEntry>[];
|
||
|
||
if (examples.isEmpty) {
|
||
return const InterestLearningScore(
|
||
points: 0,
|
||
positivePoints: 0,
|
||
negativePoints: 0,
|
||
channelPoints: 0,
|
||
reasons: <String>[],
|
||
);
|
||
}
|
||
|
||
final candidateSpecific = _specificTerms(video, effective);
|
||
final candidateFull = _fullTerms(video);
|
||
final author = video.author.trim().toLowerCase();
|
||
|
||
double positiveRaw = 0;
|
||
double negativeRaw = 0;
|
||
double topicNegativeRaw = 0;
|
||
int sameAuthorPositive = 0;
|
||
int sameAuthorNegative = 0;
|
||
|
||
for (final entry in examples) {
|
||
final exampleSpecific = _specificTerms(entry.video, effective);
|
||
final exampleFull = _fullTerms(entry.video);
|
||
final qualitySimilarity = _similarity(candidateSpecific, exampleSpecific);
|
||
final topicSimilarity = _similarity(candidateFull, exampleFull);
|
||
final sameAuthor = author.isNotEmpty &&
|
||
author == entry.video.author.trim().toLowerCase();
|
||
|
||
if (entry.rating >= 4 && !entry.notInterested) {
|
||
final weight = entry.rating == 5 ? 1.35 : 1.0;
|
||
positiveRaw += qualitySimilarity * weight;
|
||
if (sameAuthor) {
|
||
sameAuthorPositive += entry.rating == 5 ? 2 : 1;
|
||
}
|
||
}
|
||
|
||
if (entry.rating > 0 && entry.rating <= 2 || entry.badQuality) {
|
||
final weight = entry.badQuality || entry.rating == 1 ? 1.35 : 1.0;
|
||
negativeRaw += qualitySimilarity * weight;
|
||
if (sameAuthor) {
|
||
sameAuthorNegative += entry.rating == 1 || entry.badQuality ? 2 : 1;
|
||
}
|
||
}
|
||
|
||
if (entry.notInterested) {
|
||
topicNegativeRaw += topicSimilarity * 1.15;
|
||
}
|
||
}
|
||
|
||
final positivePoints = min(28, (positiveRaw * 24).round());
|
||
final negativePoints = min(
|
||
36,
|
||
(negativeRaw * 28 + topicNegativeRaw * 24).round(),
|
||
);
|
||
final channelPoints = (sameAuthorPositive * 5 - sameAuthorNegative * 7)
|
||
.clamp(-18, 14)
|
||
.toInt();
|
||
|
||
final points = (positivePoints - negativePoints + channelPoints)
|
||
.clamp(-45, 36)
|
||
.toInt();
|
||
|
||
final reasons = <String>[];
|
||
if (positivePoints >= 5) {
|
||
reasons.add('Proche de tes vidéos 4–5★ dans $effective');
|
||
}
|
||
if (negativePoints >= 5) {
|
||
reasons.add('Éloignée des vidéos mal notées dans $effective');
|
||
}
|
||
if (channelPoints >= 5) {
|
||
reasons.add('Chaîne bien notée dans $effective');
|
||
} else if (channelPoints <= -5) {
|
||
reasons.add('Chaîne déjà mal notée dans $effective');
|
||
}
|
||
|
||
return InterestLearningScore(
|
||
points: points,
|
||
positivePoints: positivePoints,
|
||
negativePoints: negativePoints,
|
||
channelPoints: channelPoints,
|
||
reasons: List<String>.unmodifiable(reasons),
|
||
);
|
||
}
|
||
|
||
int _migrateLegacyHorrorFeedback() {
|
||
final legacy = _entries['Horreur'];
|
||
if (legacy == null || legacy.isEmpty) {
|
||
_entries.remove('Horreur');
|
||
return 0;
|
||
}
|
||
|
||
final target = _entries.putIfAbsent(
|
||
'Paranormal',
|
||
() => <String, InterestFeedbackEntry>{},
|
||
);
|
||
|
||
int migrated = 0;
|
||
for (final item in legacy.entries) {
|
||
final entry = item.value;
|
||
final existing = target[item.key];
|
||
if (existing == null || entry.updatedAt > existing.updatedAt) {
|
||
target[item.key] = InterestFeedbackEntry(
|
||
video: entry.video,
|
||
category: 'Paranormal',
|
||
rating: entry.rating,
|
||
badQuality: entry.badQuality,
|
||
notInterested: entry.notInterested,
|
||
updatedAt: entry.updatedAt,
|
||
);
|
||
}
|
||
migrated++;
|
||
}
|
||
|
||
_entries.remove('Horreur');
|
||
return migrated;
|
||
}
|
||
|
||
int _migrateLegacyOtherFeedback() {
|
||
final legacy = _entries['Autres'];
|
||
if (legacy == null || legacy.isEmpty) {
|
||
return 0;
|
||
}
|
||
|
||
int migrated = 0;
|
||
final ids = legacy.keys.toList(growable: false);
|
||
for (final id in ids) {
|
||
final entry = legacy[id];
|
||
if (entry == null) {
|
||
continue;
|
||
}
|
||
|
||
final detected = classifyVideo(entry.video);
|
||
if (detected == 'Autres' || !categories.contains(detected)) {
|
||
continue;
|
||
}
|
||
|
||
final target = _entries.putIfAbsent(
|
||
detected,
|
||
() => <String, InterestFeedbackEntry>{},
|
||
);
|
||
final existing = target[id];
|
||
if (existing == null || entry.updatedAt > existing.updatedAt) {
|
||
target[id] = InterestFeedbackEntry(
|
||
video: entry.video,
|
||
category: detected,
|
||
rating: entry.rating,
|
||
badQuality: entry.badQuality,
|
||
notInterested: entry.notInterested,
|
||
updatedAt: entry.updatedAt,
|
||
);
|
||
}
|
||
legacy.remove(id);
|
||
migrated++;
|
||
}
|
||
|
||
if (legacy.isEmpty) {
|
||
_entries.remove('Autres');
|
||
}
|
||
return migrated;
|
||
}
|
||
|
||
Future<void> _loadCategoryOverrides() async {
|
||
final videoRaw = await _preferences.getString(_videoCategoryOverridesKey);
|
||
final channelRaw = await _preferences.getString(_channelCategoryOverridesKey);
|
||
|
||
_videoCategoryOverrides
|
||
..clear()
|
||
..addAll(_decodeCategoryOverrideMap(videoRaw));
|
||
_channelCategoryOverrides
|
||
..clear()
|
||
..addAll(_decodeCategoryOverrideMap(channelRaw));
|
||
|
||
bool migrated = false;
|
||
for (final map in <Map<String, String>>[
|
||
_videoCategoryOverrides,
|
||
_channelCategoryOverrides,
|
||
]) {
|
||
for (final key in map.keys.toList(growable: false)) {
|
||
if (map[key] == 'Horreur') {
|
||
map[key] = 'Paranormal';
|
||
migrated = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (migrated) {
|
||
await _persistCategoryOverrides();
|
||
}
|
||
}
|
||
|
||
Map<String, String> _decodeCategoryOverrideMap(String? raw) {
|
||
if (raw == null || raw.trim().isEmpty) {
|
||
return <String, String>{};
|
||
}
|
||
|
||
try {
|
||
final decoded = jsonDecode(raw);
|
||
if (decoded is! Map) {
|
||
return <String, String>{};
|
||
}
|
||
|
||
final result = <String, String>{};
|
||
for (final entry in decoded.entries) {
|
||
final value = '${entry.value}';
|
||
final migrated = value == 'Horreur' ? 'Paranormal' : value;
|
||
if (categories.contains(migrated) && migrated != allCategory) {
|
||
result['${entry.key}'] = migrated;
|
||
}
|
||
}
|
||
return result;
|
||
} catch (_) {
|
||
return <String, String>{};
|
||
}
|
||
}
|
||
|
||
Future<void> _persistCategoryOverrides() async {
|
||
await _preferences.setString(
|
||
_videoCategoryOverridesKey,
|
||
jsonEncode(_videoCategoryOverrides),
|
||
);
|
||
await _preferences.setString(
|
||
_channelCategoryOverridesKey,
|
||
jsonEncode(_channelCategoryOverrides),
|
||
);
|
||
}
|
||
|
||
String? _safeAssignableCategory(String? category) {
|
||
if (category == null || category == allCategory) {
|
||
return null;
|
||
}
|
||
return categories.contains(category) ? category : null;
|
||
}
|
||
|
||
List<String> _channelLookupKeys(VideoItem video) {
|
||
final keys = <String>[];
|
||
final channelId = video.channelId?.trim();
|
||
if (channelId != null && channelId.isNotEmpty) {
|
||
keys.add('id:$channelId');
|
||
}
|
||
|
||
final author = _normalize(video.author).trim();
|
||
if (author.isNotEmpty) {
|
||
keys.add('name:$author');
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
bool _sameChannel(VideoItem left, VideoItem right) {
|
||
final leftId = left.channelId?.trim();
|
||
final rightId = right.channelId?.trim();
|
||
if (leftId != null &&
|
||
leftId.isNotEmpty &&
|
||
rightId != null &&
|
||
rightId.isNotEmpty &&
|
||
leftId == rightId) {
|
||
return true;
|
||
}
|
||
|
||
final leftAuthor = _normalize(left.author).trim();
|
||
final rightAuthor = _normalize(right.author).trim();
|
||
return leftAuthor.isNotEmpty && leftAuthor == rightAuthor;
|
||
}
|
||
|
||
void _moveFeedbackForVideoToCategory(VideoItem video, String target) {
|
||
if (target == allCategory ||
|
||
(!categories.contains(target) && target != 'Autres')) {
|
||
return;
|
||
}
|
||
|
||
InterestFeedbackEntry? latest;
|
||
for (final bucket in _entries.values) {
|
||
final candidate = bucket.remove(video.id);
|
||
if (candidate != null &&
|
||
(latest == null || candidate.updatedAt > latest.updatedAt)) {
|
||
latest = candidate;
|
||
}
|
||
}
|
||
_entries.removeWhere((key, value) => value.isEmpty);
|
||
|
||
if (latest == null) {
|
||
return;
|
||
}
|
||
|
||
_put(
|
||
InterestFeedbackEntry(
|
||
video: latest.video,
|
||
category: target,
|
||
rating: latest.rating,
|
||
badQuality: latest.badQuality,
|
||
notInterested: latest.notInterested,
|
||
updatedAt: latest.updatedAt,
|
||
),
|
||
);
|
||
}
|
||
|
||
void _rehomeFeedbackForChannel(VideoItem channelVideo) {
|
||
final entries = _entries.values
|
||
.expand((bucket) => bucket.values)
|
||
.where((entry) => _sameChannel(entry.video, channelVideo))
|
||
.toList(growable: false);
|
||
|
||
for (final entry in entries) {
|
||
final target = manualCategoryFor(entry.video) ??
|
||
classifyVideoAutomatically(entry.video);
|
||
_moveFeedbackForVideoToCategory(entry.video, target);
|
||
}
|
||
}
|
||
|
||
Future<void> _persistLoadedEntries() async {
|
||
final payload = <String, dynamic>{
|
||
for (final category in _entries.entries)
|
||
category.key: <String, dynamic>{
|
||
for (final entry in category.value.entries)
|
||
entry.key: entry.value.toJson(),
|
||
},
|
||
};
|
||
await _preferences.setString(_entriesKey, jsonEncode(payload));
|
||
}
|
||
|
||
List<InterestFeedbackEntry> _entriesForScoringCategory(String category) {
|
||
if (category != allCategory) {
|
||
return _entries[category]?.values.toList() ??
|
||
const <InterestFeedbackEntry>[];
|
||
}
|
||
|
||
return _entries.values
|
||
.expand((bucket) => bucket.values)
|
||
.toList(growable: false);
|
||
}
|
||
|
||
void _put(InterestFeedbackEntry entry) {
|
||
_entries.putIfAbsent(
|
||
entry.category,
|
||
() => <String, InterestFeedbackEntry>{},
|
||
)[entry.video.id] = entry;
|
||
}
|
||
|
||
Future<void> _persist() async {
|
||
final payload = <String, dynamic>{
|
||
for (final category in _entries.entries)
|
||
category.key: <String, dynamic>{
|
||
for (final entry in category.value.entries)
|
||
entry.key: entry.value.toJson(),
|
||
},
|
||
};
|
||
|
||
await _preferences.setString(_entriesKey, jsonEncode(payload));
|
||
revision.value++;
|
||
}
|
||
|
||
Set<String> _fullTerms(VideoItem video) {
|
||
return _terms('${video.title} ${video.description} ${video.author}');
|
||
}
|
||
|
||
Set<String> _specificTerms(VideoItem video, String category) {
|
||
final result = _terms('${video.title} ${video.description}');
|
||
final generic = <String>{
|
||
...?_categoryKeywords[category]?.expand(_terms),
|
||
..._genericQualityTerms,
|
||
};
|
||
result.removeAll(generic);
|
||
return result;
|
||
}
|
||
|
||
double _similarity(Set<String> left, Set<String> right) {
|
||
if (left.isEmpty || right.isEmpty) {
|
||
return 0;
|
||
}
|
||
|
||
final intersection = left.intersection(right).length;
|
||
if (intersection == 0) {
|
||
return 0;
|
||
}
|
||
|
||
final denominator = max(3, min(left.length, right.length));
|
||
return (intersection / denominator).clamp(0.0, 1.0);
|
||
}
|
||
|
||
Set<String> _terms(String value) {
|
||
final normalized = _normalize(value);
|
||
return normalized
|
||
.split(RegExp(r'\s+'))
|
||
.map((item) => item.trim())
|
||
.where(
|
||
(item) => item.length >= 3 && !_stopWords.contains(item),
|
||
)
|
||
.toSet();
|
||
}
|
||
|
||
bool _containsWholeKeyword(String haystack, String keyword) {
|
||
final normalizedHaystack = _normalize(haystack).trim();
|
||
final normalizedKeyword = _normalize(keyword).trim();
|
||
if (normalizedHaystack.isEmpty || normalizedKeyword.isEmpty) {
|
||
return false;
|
||
}
|
||
|
||
// Les espaces jouent ici le rôle de frontières de mots après _normalize.
|
||
// Cela évite notamment que le mot-clé court `ai` corresponde à `trait`,
|
||
// `maison`, etc. Les expressions multi-mots restent supportées.
|
||
return ' $normalizedHaystack '.contains(' $normalizedKeyword ');
|
||
}
|
||
|
||
String _normalize(String value) {
|
||
return value
|
||
.toLowerCase()
|
||
.replaceAll(
|
||
RegExp(
|
||
r"[^a-z0-9à-ÿ\u0600-\u06ff]+",
|
||
caseSensitive: false,
|
||
),
|
||
' ',
|
||
);
|
||
}
|
||
|
||
static const Set<String> _genericQualityTerms = <String>{
|
||
'video',
|
||
'youtube',
|
||
'officiel',
|
||
'official',
|
||
'francais',
|
||
'français',
|
||
'english',
|
||
'arabe',
|
||
'arabic',
|
||
'nouveau',
|
||
'new',
|
||
'2025',
|
||
'2026',
|
||
};
|
||
|
||
static const Set<String> _stopWords = <String>{
|
||
'the', 'and', 'for', 'with', 'from', 'this', 'that', 'you', 'your',
|
||
'les', 'des', 'une', 'dans', 'pour', 'avec', 'sur', 'est', 'sont',
|
||
'qui', 'que', 'plus', 'tout', 'tous', 'aux', 'par', 'pas', 'mais',
|
||
'من', 'في', 'على', 'الى', 'إلى', 'عن', 'مع', 'هذا', 'هذه', 'التي',
|
||
'und', 'der', 'die', 'das', 'mit', 'von', 'für',
|
||
};
|
||
|
||
static const Map<String, List<String>> _categoryKeywords =
|
||
<String, List<String>>{
|
||
'Politique': <String>[
|
||
'politique', 'politics', 'président', 'president', 'gouvernement',
|
||
'government', 'parlement', 'élection', 'election', 'géopolitique',
|
||
'geopolitics', 'ministre', 'minister', 'diplomatie', 'diplomacy',
|
||
'macron', 'trump', 'biden', 'poutine', 'putin', 'ukraine', 'gaza',
|
||
'israel', 'iran', 'onu', 'nato', 'otan',
|
||
],
|
||
'Jeux vidéo': <String>[
|
||
'jeu vidéo', 'jeux vidéo', 'gaming', 'gameplay', 'gamer', 'game',
|
||
'playstation', 'ps5', 'xbox', 'nintendo', 'switch', 'steam',
|
||
'minecraft', 'roblox', 'fortnite', 'gta', 'call of duty', 'pokemon',
|
||
'elden ring', 'cyberpunk', 'console',
|
||
],
|
||
'Documentaires': <String>[
|
||
'documentaire', 'documentary', 'reportage', 'enquête', 'enquete',
|
||
'investigation', 'histoire', 'history', 'civilisation', 'civilization',
|
||
'archéologie', 'archeology', 'nature', 'wildlife', 'arte',
|
||
'national geographic', 'discovery channel', 'science documentaire',
|
||
],
|
||
'Paranormal': <String>[
|
||
'horreur', 'horror', 'épouvante', 'epouvante', 'terrifiant',
|
||
'terrifiante', 'effrayant', 'effrayante', 'scary', 'creepy',
|
||
'slasher', 'jumpscare', 'jump scare', 'creepypasta', 'analog horror',
|
||
'found footage', 'film d horreur', 'horror movie', 'horror film',
|
||
'paranormal', 'haunted', 'haunting', 'ghost', 'ghosts', 'spectre',
|
||
'fantôme', 'fantome', 'maison hantée', 'maison hantee',
|
||
'possession', 'démon', 'demon', 'demonic', 'exorcisme', 'exorcism',
|
||
'resident evil', 'silent hill', 'outlast', 'amnesia horror',
|
||
'jeu d horreur', 'horror game',
|
||
'رعب', 'مرعب', 'مرعبة', 'مخيف', 'مخيفة', 'قصة رعب', 'قصص رعب',
|
||
'اشباح', 'أشباح', 'شبح', 'مسكون', 'مسكونة',
|
||
'ملعون', 'ملعونة', 'لعنة', 'مسحور', 'مسحورة', 'عالم آخر',
|
||
'عالم الجن', 'الجن', 'دوار مهجور', 'قرية مهجورة', 'مكان مهجور',
|
||
'اختفى', 'اختفاء', 'مكان غريب', 'قصة مخيفة', 'قصة مرعبة',
|
||
],
|
||
'Islam': <String>[
|
||
'islam', 'islamique', 'islamic', 'musulman', 'muslim', 'coran',
|
||
'quran', 'hadith', 'sunna', 'sunnah', 'ramadan', 'allah', 'mosquée',
|
||
'mosque', 'imam', 'prophète', 'prophet', 'muhammad', 'mohammed',
|
||
'الاسلام', 'الإسلام', 'القرآن', 'قرآن', 'حديث', 'مسلم', 'الله',
|
||
],
|
||
'Football': <String>[
|
||
'football', 'soccer', 'fifa', 'champions league', 'premier league',
|
||
'la liga', 'bundesliga', 'serie a', 'ligue 1', 'real madrid',
|
||
'barcelona', 'barça', 'psg', 'manchester', 'liverpool', 'arsenal',
|
||
'bayern', 'juventus', 'inter milan', 'milan', 'mbappé', 'mbappe',
|
||
'messi', 'ronaldo', 'ballon d or',
|
||
],
|
||
'High-tech': <String>[
|
||
'high tech', 'high-tech', 'technologie', 'technology', 'tech',
|
||
'android', 'windows', 'linux', 'iphone', 'ios', 'apple', 'macbook',
|
||
'samsung', 'galaxy', 'xiaomi', 'redmi', 'poco', 'huawei', 'honor',
|
||
'oneplus', 'oppo', 'vivo', 'realme', 'google pixel', 'pixel',
|
||
'blackberry', 'black berry', 'بلاك بيري', 'بلاكبيري',
|
||
'سمارتفون', 'هاتف ذكي', 'هواتف ذكية', 'هاتف', 'هواتف', 'تقنية',
|
||
'تكنولوجيا', 'اندرويد', 'أندرويد', 'سامسونج', 'شاومي', 'ايفون',
|
||
'آيفون', 'هواوي', 'هونر', 'smartphone', 'phone', 'mobile', 'laptop',
|
||
'ordinateur', 'ordinateur gaming', 'pc', 'tablet', 'tablette',
|
||
'gpu', 'nvidia', 'geforce', 'radeon', 'amd', 'intel', 'snapdragon',
|
||
'qualcomm', 'mediatek', 'mediatek dimensity', 'usb', 'wifi', 'bluetooth',
|
||
'oled', 'amoled', 'review', 'test smartphone', 'comparatif smartphone',
|
||
'intelligence artificielle', 'artificial intelligence', 'ai', 'chatgpt',
|
||
'openai', 'robot', 'machine learning', 'deep learning',
|
||
],
|
||
};
|
||
}
|