Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user