Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:file_saver/file_saver.dart';
import 'package:file_selector/file_selector.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum MemoryImportMode { merge, replace }
class MemoryExportResult {
const MemoryExportResult({
required this.entryCount,
required this.destination,
});
final int entryCount;
final String destination;
}
class MemoryImportCandidate {
const MemoryImportCandidate({
required this.sourcePlatform,
required this.sourceDeviceId,
required this.exportedAtUtc,
required this.values,
});
final String sourcePlatform;
final String sourceDeviceId;
final DateTime? exportedAtUtc;
final Map<String, Object?> values;
int get entryCount => values.length;
}
class MemoryImportResult {
const MemoryImportResult({
required this.importedCount,
required this.removedCount,
required this.mode,
});
final int importedCount;
final int removedCount;
final MemoryImportMode mode;
}
/// Sauvegarde portable de la mémoire comportementale de YouTube Personal.
///
/// Important : ce service utilise volontairement une liste blanche. Les
/// identifiants Google, tokens OAuth, secrets, chemins de cache et autres
/// données propres à une plateforme ne sont jamais exportés.
class MemoryBackupService {
MemoryBackupService._();
static final MemoryBackupService instance = MemoryBackupService._();
static const String formatId = 'youtube-personal-memory';
static const int schemaVersion = 1;
static const String _deviceIdKey = 'youtube_personal.sync.device_id.v1';
static const String _lastImportKey =
'youtube_personal.sync.last_import_utc.v1';
static const Set<String> _allowedExactKeys = <String>{
'youtube_personal.history.v1',
'youtube_personal.favorites.v1',
'youtube_personal.positions.v1',
'youtube_personal.watch_counts.v1',
'youtube_personal.hidden_channels.v1',
// Ces deux réglages influencent directement les recommandations.
'youtube_personal.settings.preferred_language.v1',
'youtube_personal.settings.young_audience_filter.v1',
};
static const List<String> _allowedPrefixes = <String>[
'youtube_personal.radar.',
'youtube_personal.interest_',
];
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
Future<int> memoryEntryCount() async {
final all = await _preferences.getAll();
return all.keys.where(_isMemoryKey).length;
}
Future<MemoryExportResult?> exportMemory() async {
final all = await _preferences.getAll();
final memory = <String, Object?>{};
for (final entry in all.entries) {
if (!_isMemoryKey(entry.key)) {
continue;
}
memory[entry.key] = entry.value;
}
final deviceId = await _getOrCreateDeviceId();
final now = DateTime.now().toUtc();
final payload = <String, Object?>{
'format': formatId,
'schemaVersion': schemaVersion,
'exportedAtUtc': now.toIso8601String(),
'sourcePlatform': Platform.operatingSystem,
'sourceDeviceId': deviceId,
'entryCount': memory.length,
'preferences': <String, Object?>{
for (final entry in memory.entries)
entry.key: _encodePreference(entry.value),
},
};
final jsonText = const JsonEncoder.withIndent(' ').convert(payload);
final bytes = Uint8List.fromList(utf8.encode(jsonText));
final fileName = 'YouTube_Personal_Memory_${_fileTimestamp(now)}.json';
final baseName = fileName.substring(
0,
fileName.length - '.ypmemory'.length,
);
final destination = await FileSaver.instance.saveAs(
name: baseName,
bytes: bytes,
fileExtension: 'json',
mimeType: MimeType.custom,
customMimeType: 'application/json',
);
if (destination == null || destination.trim().isEmpty) {
return null;
}
return MemoryExportResult(
entryCount: memory.length,
destination: destination,
);
}
Future<MemoryImportCandidate?> pickMemoryBackup() async {
final XFile? selected;
if (Platform.isAndroid) {
// Android ne mappe pas toujours l'extension personnalisée .ypmemory
// vers un type MIME. Le contenu est validé strictement juste après.
selected = await openFile();
} else {
const memoryFiles = XTypeGroup(
label: 'Mémoire YouTube Personal',
extensions: <String>['json', 'ypmemory'],
);
selected = await openFile(acceptedTypeGroups: <XTypeGroup>[memoryFiles]);
}
if (selected == null) {
return null;
}
final bytes = await selected.readAsBytes();
if (bytes.isEmpty) {
throw const FormatException('Impossible de lire le fichier sélectionné.');
}
final rawText = utf8.decode(bytes, allowMalformed: false);
final decoded = jsonDecode(rawText);
if (decoded is! Map) {
throw const FormatException('Sauvegarde mémoire invalide.');
}
final map = decoded.map((key, value) => MapEntry('$key', value));
if (map['format'] != formatId) {
throw const FormatException(
'Ce fichier n’est pas une sauvegarde YouTube Personal.',
);
}
final rawSchema = map['schemaVersion'];
final schema = rawSchema is num ? rawSchema.toInt() : -1;
if (schema <= 0 || schema > schemaVersion) {
throw FormatException(
'Version de sauvegarde non prise en charge : $schema.',
);
}
final rawPreferences = map['preferences'];
if (rawPreferences is! Map) {
throw const FormatException('La mémoire du fichier est absente.');
}
final values = <String, Object?>{};
for (final entry in rawPreferences.entries) {
final key = '${entry.key}';
if (!_isMemoryKey(key)) {
// Défense supplémentaire : même un fichier modifié manuellement ne
// peut pas injecter une préférence sensible hors de la liste blanche.
continue;
}
final rawValue = entry.value;
if (rawValue is! Map) {
continue;
}
values[key] = _decodePreference(
rawValue.map((key, value) => MapEntry('$key', value)),
);
}
DateTime? exportedAt;
final rawExportedAt = map['exportedAtUtc'];
if (rawExportedAt is String) {
exportedAt = DateTime.tryParse(rawExportedAt)?.toUtc();
}
return MemoryImportCandidate(
sourcePlatform: map['sourcePlatform'] is String
? map['sourcePlatform'] as String
: 'inconnu',
sourceDeviceId: map['sourceDeviceId'] is String
? map['sourceDeviceId'] as String
: 'inconnu',
exportedAtUtc: exportedAt,
values: values,
);
}
Future<MemoryImportResult> importMemory(
MemoryImportCandidate candidate, {
required MemoryImportMode mode,
}) async {
int removed = 0;
if (mode == MemoryImportMode.replace) {
final current = await _preferences.getAll();
for (final key in current.keys.where(_isMemoryKey)) {
await _preferences.remove(key);
removed++;
}
}
int imported = 0;
for (final entry in candidate.values.entries) {
if (!_isMemoryKey(entry.key)) {
continue;
}
await _writePreference(entry.key, entry.value);
imported++;
}
await _preferences.setString(
_lastImportKey,
DateTime.now().toUtc().toIso8601String(),
);
return MemoryImportResult(
importedCount: imported,
removedCount: removed,
mode: mode,
);
}
bool _isMemoryKey(String key) {
if (_allowedExactKeys.contains(key)) {
return true;
}
return _allowedPrefixes.any(key.startsWith);
}
Map<String, Object?> _encodePreference(Object? value) {
if (value is bool) {
return <String, Object?>{'type': 'bool', 'value': value};
}
if (value is int) {
return <String, Object?>{'type': 'int', 'value': value};
}
if (value is double) {
return <String, Object?>{'type': 'double', 'value': value};
}
if (value is String) {
return <String, Object?>{'type': 'string', 'value': value};
}
if (value is List<String>) {
return <String, Object?>{'type': 'stringList', 'value': value};
}
if (value is List && value.every((item) => item is String)) {
return <String, Object?>{
'type': 'stringList',
'value': value.cast<String>(),
};
}
throw FormatException(
'Type de préférence non pris en charge : ${value.runtimeType}',
);
}
Object? _decodePreference(Map<String, dynamic> encoded) {
final type = encoded['type'];
final value = encoded['value'];
switch (type) {
case 'bool':
if (value is bool) return value;
break;
case 'int':
if (value is num) return value.toInt();
break;
case 'double':
if (value is num) return value.toDouble();
break;
case 'string':
if (value is String) return value;
break;
case 'stringList':
if (value is List && value.every((item) => item is String)) {
return value.cast<String>();
}
break;
}
throw FormatException('Valeur mémoire invalide pour le type $type.');
}
Future<void> _writePreference(String key, Object? value) async {
if (value is bool) {
await _preferences.setBool(key, value);
return;
}
if (value is int) {
await _preferences.setInt(key, value);
return;
}
if (value is double) {
await _preferences.setDouble(key, value);
return;
}
if (value is String) {
await _preferences.setString(key, value);
return;
}
if (value is List<String>) {
await _preferences.setStringList(key, value);
return;
}
throw FormatException(
'Type mémoire impossible à importer : ${value.runtimeType}',
);
}
Future<String> _getOrCreateDeviceId() async {
final existing = await _preferences.getString(_deviceIdKey);
if (existing != null && existing.trim().isNotEmpty) {
return existing;
}
final random = Random.secure();
final bytes = List<int>.generate(12, (_) => random.nextInt(256));
final randomPart = bytes
.map((value) => value.toRadixString(16).padLeft(2, '0'))
.join();
final id = '${Platform.operatingSystem}-$randomPart';
await _preferences.setString(_deviceIdKey, id);
return id;
}
String _fileTimestamp(DateTime value) {
String two(int number) => number.toString().padLeft(2, '0');
return '${value.year}-${two(value.month)}-${two(value.day)}_'
'${two(value.hour)}-${two(value.minute)}-${two(value.second)}Z';
}
}