Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'preferred_language_service.dart';
|
||||
|
||||
class AppSettingsService {
|
||||
AppSettingsService._();
|
||||
|
||||
static final AppSettingsService instance = AppSettingsService._();
|
||||
|
||||
static const String _defaultResolutionKey =
|
||||
'youtube_personal.settings.default_resolution.v1';
|
||||
static const String _preferredLanguageKey =
|
||||
'youtube_personal.settings.preferred_language.v1';
|
||||
static const String _youngAudienceFilterKey =
|
||||
'youtube_personal.settings.young_audience_filter.v1';
|
||||
static const String _pauseScreenSleepMinutesKey =
|
||||
'youtube_personal.settings.pause_screen_sleep_minutes.v1';
|
||||
static const String _appearanceModeKey =
|
||||
'youtube_personal.settings.appearance_mode.v1';
|
||||
|
||||
static const int historicalDefaultResolution = 1080;
|
||||
|
||||
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
|
||||
|
||||
final ValueNotifier<int> defaultResolution =
|
||||
ValueNotifier<int>(historicalDefaultResolution);
|
||||
|
||||
final ValueNotifier<String> preferredLanguage =
|
||||
ValueNotifier<String>('auto');
|
||||
|
||||
final ValueNotifier<int> youngAudienceFilterLevel =
|
||||
ValueNotifier<int>(0);
|
||||
|
||||
final ValueNotifier<int> pauseScreenSleepMinutes =
|
||||
ValueNotifier<int>(5);
|
||||
|
||||
final ValueNotifier<String> appearanceMode =
|
||||
ValueNotifier<String>('light');
|
||||
|
||||
Future<void> initialize() async {
|
||||
final storedResolution = await _preferences.getInt(_defaultResolutionKey);
|
||||
final storedLanguage = await _preferences.getString(_preferredLanguageKey);
|
||||
final storedYoungAudienceFilter =
|
||||
await _preferences.getInt(_youngAudienceFilterKey);
|
||||
final storedPauseScreenSleepMinutes =
|
||||
await _preferences.getInt(_pauseScreenSleepMinutesKey);
|
||||
final storedAppearanceMode =
|
||||
await _preferences.getString(_appearanceModeKey);
|
||||
|
||||
if (storedResolution != null &&
|
||||
supportedResolutions.contains(storedResolution)) {
|
||||
defaultResolution.value = storedResolution;
|
||||
}
|
||||
|
||||
if (storedLanguage != null &&
|
||||
PreferredLanguageService.isSupported(storedLanguage)) {
|
||||
preferredLanguage.value = storedLanguage;
|
||||
}
|
||||
|
||||
if (storedYoungAudienceFilter != null &&
|
||||
supportedYoungAudienceFilterLevels
|
||||
.contains(storedYoungAudienceFilter)) {
|
||||
youngAudienceFilterLevel.value = storedYoungAudienceFilter;
|
||||
}
|
||||
|
||||
if (storedPauseScreenSleepMinutes != null &&
|
||||
supportedPauseScreenSleepMinutes
|
||||
.contains(storedPauseScreenSleepMinutes)) {
|
||||
pauseScreenSleepMinutes.value = storedPauseScreenSleepMinutes;
|
||||
}
|
||||
|
||||
if (storedAppearanceMode != null &&
|
||||
supportedAppearanceModes.contains(storedAppearanceMode)) {
|
||||
appearanceMode.value = storedAppearanceMode;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAppearanceMode(String mode) async {
|
||||
if (!supportedAppearanceModes.contains(mode)) {
|
||||
return;
|
||||
}
|
||||
if (appearanceMode.value == mode) {
|
||||
return;
|
||||
}
|
||||
appearanceMode.value = mode;
|
||||
await _preferences.setString(_appearanceModeKey, mode);
|
||||
}
|
||||
|
||||
Future<void> setDefaultResolution(int resolution) async {
|
||||
if (!supportedResolutions.contains(resolution)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultResolution.value == resolution) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultResolution.value = resolution;
|
||||
await _preferences.setInt(_defaultResolutionKey, resolution);
|
||||
}
|
||||
|
||||
Future<void> setPreferredLanguage(String languageCode) async {
|
||||
if (!PreferredLanguageService.isSupported(languageCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preferredLanguage.value == languageCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
preferredLanguage.value = languageCode;
|
||||
await _preferences.setString(_preferredLanguageKey, languageCode);
|
||||
}
|
||||
|
||||
Future<void> setPauseScreenSleepMinutes(int minutes) async {
|
||||
if (!supportedPauseScreenSleepMinutes.contains(minutes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pauseScreenSleepMinutes.value == minutes) {
|
||||
return;
|
||||
}
|
||||
|
||||
pauseScreenSleepMinutes.value = minutes;
|
||||
await _preferences.setInt(_pauseScreenSleepMinutesKey, minutes);
|
||||
}
|
||||
|
||||
Future<void> setYoungAudienceFilterLevel(int level) async {
|
||||
if (!supportedYoungAudienceFilterLevels.contains(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (youngAudienceFilterLevel.value == level) {
|
||||
return;
|
||||
}
|
||||
|
||||
youngAudienceFilterLevel.value = level;
|
||||
await _preferences.setInt(_youngAudienceFilterKey, level);
|
||||
}
|
||||
|
||||
static const List<String> supportedAppearanceModes = <String>[
|
||||
'auto',
|
||||
'dark',
|
||||
'light',
|
||||
'system',
|
||||
];
|
||||
|
||||
static String appearanceModeLabel(String mode) {
|
||||
return switch (mode) {
|
||||
'dark' => 'Sombre',
|
||||
'light' => 'Clair',
|
||||
'auto' => 'Auto (jour/nuit)',
|
||||
_ => 'Système',
|
||||
};
|
||||
}
|
||||
|
||||
static const List<int> supportedPauseScreenSleepMinutes = <int>[
|
||||
2,
|
||||
5,
|
||||
10,
|
||||
0,
|
||||
];
|
||||
|
||||
static String pauseScreenSleepLabel(int minutes) {
|
||||
return switch (minutes) {
|
||||
0 => 'Jamais',
|
||||
2 => '2 minutes',
|
||||
10 => '10 minutes',
|
||||
_ => '5 minutes',
|
||||
};
|
||||
}
|
||||
|
||||
static const List<int> supportedYoungAudienceFilterLevels = <int>[
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
];
|
||||
|
||||
static String youngAudienceFilterLabel(int level) {
|
||||
return switch (level) {
|
||||
1 => 'Modéré',
|
||||
2 => 'Strict',
|
||||
_ => 'Désactivé',
|
||||
};
|
||||
}
|
||||
|
||||
static const List<int> supportedResolutions = <int>[
|
||||
0,
|
||||
360,
|
||||
720,
|
||||
1080,
|
||||
1440,
|
||||
2160,
|
||||
];
|
||||
|
||||
static String resolutionLabel(int resolution) {
|
||||
return switch (resolution) {
|
||||
0 => 'Auto',
|
||||
2160 => '2160p (4K)',
|
||||
_ => '${resolution}p',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
import 'library_service.dart';
|
||||
import 'sponsor_block_service.dart';
|
||||
|
||||
class BackgroundPlaybackSnapshot {
|
||||
const BackgroundPlaybackSnapshot({
|
||||
required this.position,
|
||||
required this.playing,
|
||||
required this.rate,
|
||||
});
|
||||
|
||||
final Duration position;
|
||||
final bool playing;
|
||||
final double rate;
|
||||
}
|
||||
|
||||
class BackgroundPlaybackService {
|
||||
BackgroundPlaybackService._();
|
||||
|
||||
static final BackgroundPlaybackService instance =
|
||||
BackgroundPlaybackService._();
|
||||
|
||||
MediaKitAudioHandler? _handler;
|
||||
|
||||
bool get isAvailable => Platform.isAndroid && _handler != null;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!Platform.isAndroid || _handler != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MediaKitAudioHandler? created;
|
||||
|
||||
await AudioService.init(
|
||||
builder: () {
|
||||
created = MediaKitAudioHandler();
|
||||
return created!;
|
||||
},
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId:
|
||||
'com.studioweb.youtube_personal.playback',
|
||||
androidNotificationChannelName:
|
||||
'Lecture YouTube Personal',
|
||||
androidNotificationChannelDescription:
|
||||
'Contrôles de lecture en arrière-plan',
|
||||
androidNotificationOngoing: false,
|
||||
androidStopForegroundOnPause: false,
|
||||
rewindInterval: Duration(seconds: 10),
|
||||
fastForwardInterval: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
|
||||
_handler = created;
|
||||
}
|
||||
|
||||
Future<void> attachPlayer(
|
||||
Player player,
|
||||
VideoItem video,
|
||||
) async {
|
||||
final handler = _handler;
|
||||
|
||||
if (handler == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handler.attachForegroundPlayer(
|
||||
player,
|
||||
video,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enterBackgroundAudio({
|
||||
required String url,
|
||||
required VideoItem video,
|
||||
required Duration position,
|
||||
required bool shouldPlay,
|
||||
required double rate,
|
||||
}) async {
|
||||
await _handler?.enterBackgroundAudio(
|
||||
url: url,
|
||||
video: video,
|
||||
position: position,
|
||||
shouldPlay: shouldPlay,
|
||||
rate: rate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<BackgroundPlaybackSnapshot?> exitBackgroundAudio({
|
||||
required Player foregroundPlayer,
|
||||
required VideoItem video,
|
||||
}) async {
|
||||
return _handler?.exitBackgroundAudio(
|
||||
foregroundPlayer: foregroundPlayer,
|
||||
video: video,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> detachPlayer() async {
|
||||
await _handler?.detachPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
class MediaKitAudioHandler extends BaseAudioHandler
|
||||
with SeekHandler {
|
||||
Player? _foregroundPlayer;
|
||||
Player? _backgroundPlayer;
|
||||
Player? _activePlayer;
|
||||
|
||||
VideoItem? _video;
|
||||
|
||||
final List<StreamSubscription<dynamic>> _subscriptions =
|
||||
<StreamSubscription<dynamic>>[];
|
||||
|
||||
bool _backgroundMode = false;
|
||||
|
||||
bool _playing = false;
|
||||
bool _buffering = false;
|
||||
bool _completed = false;
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
double _speed = 1.0;
|
||||
Duration _lastPersistedPosition = Duration.zero;
|
||||
|
||||
List<SponsorSegment> _backgroundSponsorSegments =
|
||||
const <SponsorSegment>[];
|
||||
bool _backgroundSponsorSeekInProgress = false;
|
||||
String? _backgroundSponsorActiveId;
|
||||
|
||||
Future<void> attachForegroundPlayer(
|
||||
Player player,
|
||||
VideoItem video,
|
||||
) async {
|
||||
_foregroundPlayer = player;
|
||||
_video = video;
|
||||
|
||||
if (_backgroundMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _bindPlayer(
|
||||
player,
|
||||
video,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enterBackgroundAudio({
|
||||
required String url,
|
||||
required VideoItem video,
|
||||
required Duration position,
|
||||
required bool shouldPlay,
|
||||
required double rate,
|
||||
}) async {
|
||||
if (_backgroundMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] switching to dedicated player '
|
||||
'at ${position.inMilliseconds} ms '
|
||||
'(shouldPlay=$shouldPlay rate=$rate)',
|
||||
);
|
||||
|
||||
_backgroundMode = true;
|
||||
_video = video;
|
||||
|
||||
unawaited(_prepareBackgroundSponsors(video.id));
|
||||
|
||||
await _cancelSubscriptions();
|
||||
|
||||
// Garde la MediaSession/foreground service en état actif pendant
|
||||
// l'ouverture du nouveau Player afin d'éviter qu'Android arrête
|
||||
// le service entre les deux moteurs.
|
||||
_playing = shouldPlay;
|
||||
_buffering = true;
|
||||
_completed = false;
|
||||
_position = position;
|
||||
_duration = video.duration ?? Duration.zero;
|
||||
_speed = rate;
|
||||
_setMediaItem(video);
|
||||
_broadcastState();
|
||||
|
||||
final backgroundPlayer = Player(
|
||||
configuration: const PlayerConfiguration(
|
||||
vo: 'null',
|
||||
title: 'YouTube Personal Background',
|
||||
),
|
||||
);
|
||||
|
||||
_backgroundPlayer = backgroundPlayer;
|
||||
_activePlayer = backgroundPlayer;
|
||||
|
||||
try {
|
||||
// Ne pas lancer immédiatement : on désactive d'abord explicitement
|
||||
// la piste vidéo pour que ce Player ne dépende d'aucune Surface Android.
|
||||
await backgroundPlayer.open(
|
||||
Media(url),
|
||||
play: false,
|
||||
);
|
||||
|
||||
await backgroundPlayer.setVideoTrack(
|
||||
VideoTrack.no(),
|
||||
);
|
||||
|
||||
// Sur certains appareils, open() retourne avant que la timeline du
|
||||
// média soit réellement prête. Un seek envoyé immédiatement peut alors
|
||||
// être ignoré et laisser position=0. On attend une durée exploitable.
|
||||
await _waitUntilMediaReady(
|
||||
backgroundPlayer,
|
||||
wantedPosition: position,
|
||||
fallbackDuration: video.duration,
|
||||
);
|
||||
|
||||
if (position > Duration.zero) {
|
||||
await _seekAndConfirm(
|
||||
backgroundPlayer,
|
||||
position,
|
||||
);
|
||||
}
|
||||
|
||||
await backgroundPlayer.setRate(rate);
|
||||
|
||||
if (shouldPlay) {
|
||||
await backgroundPlayer.play();
|
||||
|
||||
// Confirme aussi que le nouveau Player est réellement passé en
|
||||
// lecture avant de rendre la main au code écran-verrouillé.
|
||||
await _waitUntilPlaying(backgroundPlayer);
|
||||
}
|
||||
|
||||
await _bindPlayer(
|
||||
backgroundPlayer,
|
||||
video,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] dedicated player ready '
|
||||
'at ${backgroundPlayer.state.position.inMilliseconds} ms '
|
||||
'playing=${backgroundPlayer.state.playing}',
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] dedicated player failed: $error',
|
||||
);
|
||||
|
||||
_backgroundMode = false;
|
||||
_backgroundPlayer = null;
|
||||
|
||||
await backgroundPlayer.dispose();
|
||||
|
||||
final foreground = _foregroundPlayer;
|
||||
|
||||
if (foreground != null) {
|
||||
await _bindPlayer(
|
||||
foreground,
|
||||
video,
|
||||
);
|
||||
} else {
|
||||
_activePlayer = null;
|
||||
}
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<BackgroundPlaybackSnapshot?> exitBackgroundAudio({
|
||||
required Player foregroundPlayer,
|
||||
required VideoItem video,
|
||||
}) async {
|
||||
if (!_backgroundMode) {
|
||||
_foregroundPlayer = foregroundPlayer;
|
||||
|
||||
return BackgroundPlaybackSnapshot(
|
||||
position: foregroundPlayer.state.position,
|
||||
playing: foregroundPlayer.state.playing,
|
||||
rate: foregroundPlayer.state.rate,
|
||||
);
|
||||
}
|
||||
|
||||
final background = _backgroundPlayer;
|
||||
|
||||
if (background == null) {
|
||||
_backgroundMode = false;
|
||||
_foregroundPlayer = foregroundPlayer;
|
||||
|
||||
await _bindPlayer(
|
||||
foregroundPlayer,
|
||||
video,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
final snapshot = BackgroundPlaybackSnapshot(
|
||||
position: background.state.position,
|
||||
playing: background.state.playing,
|
||||
rate: background.state.rate,
|
||||
);
|
||||
|
||||
await LibraryService.instance.savePlaybackPosition(
|
||||
video.id,
|
||||
snapshot.position,
|
||||
duration: video.duration,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] returning to video player '
|
||||
'at ${snapshot.position.inMilliseconds} ms '
|
||||
'playing=${snapshot.playing}',
|
||||
);
|
||||
|
||||
// Annule les listeners avant de couper le Player de fond, sinon
|
||||
// le MediaSession verrait artificiellement une pause.
|
||||
await _cancelSubscriptions();
|
||||
|
||||
_backgroundMode = false;
|
||||
_backgroundPlayer = null;
|
||||
_foregroundPlayer = foregroundPlayer;
|
||||
_activePlayer = foregroundPlayer;
|
||||
|
||||
await background.pause();
|
||||
await background.dispose();
|
||||
|
||||
await _bindPlayer(
|
||||
foregroundPlayer,
|
||||
video,
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
Future<void> detachPlayer() async {
|
||||
await _cancelSubscriptions();
|
||||
|
||||
final background = _backgroundPlayer;
|
||||
_backgroundPlayer = null;
|
||||
|
||||
if (background != null) {
|
||||
await background.dispose();
|
||||
}
|
||||
|
||||
_foregroundPlayer = null;
|
||||
_activePlayer = null;
|
||||
_video = null;
|
||||
_backgroundMode = false;
|
||||
|
||||
_playing = false;
|
||||
_buffering = false;
|
||||
_completed = false;
|
||||
_position = Duration.zero;
|
||||
_duration = Duration.zero;
|
||||
_speed = 1.0;
|
||||
_lastPersistedPosition = Duration.zero;
|
||||
_backgroundSponsorSegments = const <SponsorSegment>[];
|
||||
_backgroundSponsorSeekInProgress = false;
|
||||
_backgroundSponsorActiveId = null;
|
||||
|
||||
mediaItem.add(null);
|
||||
|
||||
playbackState.add(
|
||||
PlaybackState(
|
||||
processingState: AudioProcessingState.idle,
|
||||
playing: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitUntilMediaReady(
|
||||
Player player, {
|
||||
required Duration wantedPosition,
|
||||
required Duration? fallbackDuration,
|
||||
}) async {
|
||||
final currentDuration = player.state.duration;
|
||||
|
||||
if (currentDuration > Duration.zero &&
|
||||
(wantedPosition <= Duration.zero ||
|
||||
currentDuration >= wantedPosition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await player.stream.duration
|
||||
.firstWhere(
|
||||
(duration) =>
|
||||
duration > Duration.zero &&
|
||||
(wantedPosition <= Duration.zero ||
|
||||
duration >= wantedPosition),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
} on TimeoutException {
|
||||
// Si youtube_explode nous a déjà fourni une durée cohérente, on peut
|
||||
// malgré tout tenter le seek. Le contrôle suivant confirmera ou non.
|
||||
if (fallbackDuration == null ||
|
||||
fallbackDuration <= Duration.zero ||
|
||||
(wantedPosition > Duration.zero &&
|
||||
fallbackDuration < wantedPosition)) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seekAndConfirm(
|
||||
Player player,
|
||||
Duration target,
|
||||
) async {
|
||||
const tolerance = Duration(seconds: 2);
|
||||
|
||||
for (var attempt = 1; attempt <= 3; attempt++) {
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] seek attempt $attempt -> '
|
||||
'${target.inMilliseconds} ms',
|
||||
);
|
||||
|
||||
await player.seek(target);
|
||||
|
||||
final immediate = player.state.position;
|
||||
|
||||
if (_closeEnough(
|
||||
immediate,
|
||||
target,
|
||||
tolerance,
|
||||
)) {
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] seek confirmed immediately '
|
||||
'at ${immediate.inMilliseconds} ms',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final confirmed = await player.stream.position
|
||||
.firstWhere(
|
||||
(position) => _closeEnough(
|
||||
position,
|
||||
target,
|
||||
tolerance,
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] seek confirmed '
|
||||
'at ${confirmed.inMilliseconds} ms',
|
||||
);
|
||||
return;
|
||||
} on TimeoutException {
|
||||
debugPrint(
|
||||
'[BACKGROUND-AUDIO] seek attempt $attempt not confirmed '
|
||||
'(current=${player.state.position.inMilliseconds} ms)',
|
||||
);
|
||||
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw TimeoutException(
|
||||
'Le Player audio de fond n’a pas confirmé le seek.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitUntilPlaying(Player player) async {
|
||||
if (player.state.playing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await player.stream.playing
|
||||
.firstWhere((playing) => playing)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
bool _closeEnough(
|
||||
Duration value,
|
||||
Duration target,
|
||||
Duration tolerance,
|
||||
) {
|
||||
final delta =
|
||||
(value.inMilliseconds - target.inMilliseconds).abs();
|
||||
|
||||
return delta <= tolerance.inMilliseconds;
|
||||
}
|
||||
|
||||
Future<void> _prepareBackgroundSponsors(String videoId) async {
|
||||
final segments = await SponsorBlockService.instance
|
||||
.getSponsorSegments(videoId);
|
||||
|
||||
if (_video?.id != videoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
_backgroundSponsorSegments = segments;
|
||||
_backgroundSponsorActiveId = null;
|
||||
}
|
||||
|
||||
Future<void> _maybeSkipBackgroundSponsor(
|
||||
Player player,
|
||||
Duration position,
|
||||
) async {
|
||||
if (!_backgroundMode ||
|
||||
_backgroundSponsorSeekInProgress ||
|
||||
_backgroundSponsorSegments.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
SponsorSegment? match;
|
||||
|
||||
for (final segment in _backgroundSponsorSegments) {
|
||||
if (segment.contains(position)) {
|
||||
match = segment;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match == null) {
|
||||
_backgroundSponsorActiveId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_backgroundSponsorActiveId == match.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
_backgroundSponsorActiveId = match.id;
|
||||
_backgroundSponsorSeekInProgress = true;
|
||||
|
||||
try {
|
||||
final target = match.end + const Duration(milliseconds: 80);
|
||||
final wasPlaying = player.state.playing;
|
||||
|
||||
debugPrint(
|
||||
'[BACKGROUND-SPONSORBLOCK] skip '
|
||||
'${match.start.inMilliseconds} -> ${match.end.inMilliseconds} ms',
|
||||
);
|
||||
|
||||
await player.seek(target);
|
||||
|
||||
if (wasPlaying) {
|
||||
await player.play();
|
||||
}
|
||||
|
||||
_position = target;
|
||||
_lastPersistedPosition = target;
|
||||
} catch (error) {
|
||||
debugPrint('[BACKGROUND-SPONSORBLOCK] skip impossible: $error');
|
||||
} finally {
|
||||
_backgroundSponsorSeekInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bindPlayer(
|
||||
Player player,
|
||||
VideoItem video,
|
||||
) async {
|
||||
await _cancelSubscriptions();
|
||||
|
||||
_activePlayer = player;
|
||||
_video = video;
|
||||
|
||||
_playing = player.state.playing;
|
||||
_buffering = player.state.buffering;
|
||||
_position = player.state.position;
|
||||
_duration = video.duration ?? player.state.duration;
|
||||
_speed = player.state.rate;
|
||||
_completed = false;
|
||||
_lastPersistedPosition = _position;
|
||||
|
||||
_setMediaItem(video);
|
||||
|
||||
_subscriptions.addAll([
|
||||
player.stream.playing.listen((value) {
|
||||
_playing = value;
|
||||
_broadcastState();
|
||||
}),
|
||||
player.stream.buffering.listen((value) {
|
||||
_buffering = value;
|
||||
_broadcastState();
|
||||
}),
|
||||
player.stream.position.listen((value) {
|
||||
_position = value;
|
||||
|
||||
if (_backgroundMode) {
|
||||
unawaited(_maybeSkipBackgroundSponsor(player, value));
|
||||
}
|
||||
|
||||
if (_backgroundMode &&
|
||||
(_position - _lastPersistedPosition).abs() >=
|
||||
const Duration(seconds: 5)) {
|
||||
_lastPersistedPosition = _position;
|
||||
|
||||
unawaited(
|
||||
LibraryService.instance.savePlaybackPosition(
|
||||
video.id,
|
||||
_position,
|
||||
duration: video.duration,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_broadcastState();
|
||||
}),
|
||||
player.stream.duration.listen((value) {
|
||||
if (value > Duration.zero) {
|
||||
_duration = value;
|
||||
}
|
||||
|
||||
_broadcastState();
|
||||
}),
|
||||
player.stream.rate.listen((value) {
|
||||
_speed = value;
|
||||
_broadcastState();
|
||||
}),
|
||||
player.stream.completed.listen((value) {
|
||||
_completed = value;
|
||||
_broadcastState();
|
||||
}),
|
||||
]);
|
||||
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
void _setMediaItem(VideoItem video) {
|
||||
mediaItem.add(
|
||||
MediaItem(
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
artist: video.author,
|
||||
duration: video.duration,
|
||||
artUri: Uri.tryParse(video.thumbnailUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cancelSubscriptions() async {
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
_subscriptions.clear();
|
||||
}
|
||||
|
||||
void _broadcastState() {
|
||||
final processingState = _completed
|
||||
? AudioProcessingState.completed
|
||||
: _buffering
|
||||
? AudioProcessingState.buffering
|
||||
: AudioProcessingState.ready;
|
||||
|
||||
playbackState.add(
|
||||
PlaybackState(
|
||||
controls: [
|
||||
MediaControl.rewind,
|
||||
_playing ? MediaControl.pause : MediaControl.play,
|
||||
MediaControl.fastForward,
|
||||
],
|
||||
androidCompactActionIndices: const [0, 1, 2],
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
MediaAction.setSpeed,
|
||||
},
|
||||
processingState: processingState,
|
||||
playing: _playing,
|
||||
updatePosition: _position,
|
||||
bufferedPosition: Duration.zero,
|
||||
speed: _speed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
await _activePlayer?.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
await _activePlayer?.pause();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
final target = _clampPosition(position);
|
||||
await _activePlayer?.seek(target);
|
||||
|
||||
_position = target;
|
||||
|
||||
final video = _video;
|
||||
if (_backgroundMode && video != null) {
|
||||
_lastPersistedPosition = target;
|
||||
|
||||
await LibraryService.instance.savePlaybackPosition(
|
||||
video.id,
|
||||
target,
|
||||
duration: video.duration,
|
||||
);
|
||||
}
|
||||
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rewind() async {
|
||||
await seek(
|
||||
_position - const Duration(seconds: 10),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> fastForward() async {
|
||||
await seek(
|
||||
_position + const Duration(seconds: 10),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setSpeed(double speed) async {
|
||||
await _activePlayer?.setRate(speed);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
await _activePlayer?.stop();
|
||||
|
||||
_playing = false;
|
||||
_completed = false;
|
||||
_position = Duration.zero;
|
||||
|
||||
playbackState.add(
|
||||
PlaybackState(
|
||||
processingState: AudioProcessingState.idle,
|
||||
playing: false,
|
||||
),
|
||||
);
|
||||
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
Duration _clampPosition(Duration value) {
|
||||
if (value < Duration.zero) {
|
||||
return Duration.zero;
|
||||
}
|
||||
|
||||
if (_duration > Duration.zero && value > _duration) {
|
||||
return _duration;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import '../models/channel_rank_item.dart';
|
||||
import '../models/subscription_channel.dart';
|
||||
import '../models/video_item.dart';
|
||||
import 'hidden_channels_service.dart';
|
||||
import 'library_service.dart';
|
||||
import 'youtube_service.dart';
|
||||
|
||||
class ChannelCatalogService {
|
||||
ChannelCatalogService._();
|
||||
|
||||
static final ChannelCatalogService instance =
|
||||
ChannelCatalogService._();
|
||||
|
||||
final YoutubeService _youtube = YoutubeService();
|
||||
|
||||
List<ChannelRankItem>? _cache;
|
||||
String? _cacheFingerprint;
|
||||
DateTime? _cacheAt;
|
||||
|
||||
Future<List<ChannelRankItem>> load({
|
||||
bool force = false,
|
||||
}) async {
|
||||
await HiddenChannelsService.instance.ensureLoaded();
|
||||
|
||||
final history = LibraryService.instance.history.value;
|
||||
|
||||
if (history.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final fingerprint = <String>[
|
||||
'${HiddenChannelsService.instance.revision.value}',
|
||||
for (final video in history.take(100))
|
||||
'${video.id}:${LibraryService.instance.watchCountForVideo(video.id)}',
|
||||
].join('|');
|
||||
|
||||
final cacheFresh = _cacheAt != null &&
|
||||
DateTime.now().difference(_cacheAt!) <
|
||||
const Duration(minutes: 10);
|
||||
|
||||
if (!force &&
|
||||
cacheFresh &&
|
||||
_cache != null &&
|
||||
_cacheFingerprint == fingerprint) {
|
||||
return _cache!;
|
||||
}
|
||||
|
||||
final enriched = <VideoItem>[];
|
||||
|
||||
// History is unique by video ID, but personal repeat counts are stored
|
||||
// separately. Resolve old entries created before channelId existed.
|
||||
for (var start = 0; start < history.length; start += 6) {
|
||||
final end = start + 6 < history.length
|
||||
? start + 6
|
||||
: history.length;
|
||||
|
||||
final chunk = history.sublist(start, end);
|
||||
|
||||
final resolved = await Future.wait(
|
||||
chunk.map(
|
||||
(video) async {
|
||||
if (video.channelId != null &&
|
||||
video.channelId!.isNotEmpty) {
|
||||
return video;
|
||||
}
|
||||
|
||||
try {
|
||||
return await _youtube.getVideoDetails(video.id);
|
||||
} catch (_) {
|
||||
return video;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
enriched.addAll(resolved);
|
||||
}
|
||||
|
||||
final groups = <String, _ChannelAccumulator>{};
|
||||
|
||||
for (var index = 0; index < enriched.length; index++) {
|
||||
final video = enriched[index];
|
||||
var channelId = video.channelId?.trim() ?? '';
|
||||
SubscriptionChannel? resolvedChannel;
|
||||
|
||||
if (channelId.isEmpty) {
|
||||
try {
|
||||
resolvedChannel =
|
||||
await _youtube.getChannelForVideo(video.id);
|
||||
channelId = resolvedChannel.channelId;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (HiddenChannelsService.instance.isHiddenChannel(
|
||||
channelId: channelId,
|
||||
title: resolvedChannel?.title ?? video.author,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final personalViews =
|
||||
LibraryService.instance.watchCountForVideo(video.id);
|
||||
|
||||
final safePersonalViews = personalViews > 0 ? personalViews : 1;
|
||||
|
||||
groups
|
||||
.putIfAbsent(
|
||||
channelId,
|
||||
() => _ChannelAccumulator(
|
||||
channelId: channelId,
|
||||
fallbackTitle: resolvedChannel?.title ?? video.author,
|
||||
),
|
||||
)
|
||||
.add(
|
||||
video,
|
||||
personalViews: safePersonalViews,
|
||||
historyIndex: index,
|
||||
channelOverride: resolvedChannel,
|
||||
);
|
||||
}
|
||||
|
||||
final accumulators = groups.values.toList();
|
||||
final result = <ChannelRankItem>[];
|
||||
|
||||
for (var start = 0; start < accumulators.length; start += 6) {
|
||||
final end = start + 6 < accumulators.length
|
||||
? start + 6
|
||||
: accumulators.length;
|
||||
|
||||
final chunk = accumulators.sublist(start, end);
|
||||
|
||||
final resolved = await Future.wait(
|
||||
chunk.map(
|
||||
(group) async {
|
||||
SubscriptionChannel channel;
|
||||
|
||||
if (group.channelOverride != null) {
|
||||
channel = group.channelOverride!;
|
||||
} else {
|
||||
try {
|
||||
channel = await _youtube.getChannel(group.channelId);
|
||||
} catch (_) {
|
||||
channel = SubscriptionChannel(
|
||||
channelId: group.channelId,
|
||||
title: group.fallbackTitle,
|
||||
thumbnailUrl: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (HiddenChannelsService.instance.isHiddenChannel(
|
||||
channelId: channel.channelId,
|
||||
title: channel.title,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ChannelRankItem(
|
||||
channel: channel,
|
||||
personalViewCount: group.personalViewCount,
|
||||
distinctVideoCount: group.distinctVideoCount,
|
||||
topVideoPersonalViews: group.topVideoPersonalViews,
|
||||
topVideoTitle: group.topVideoTitle,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
result.addAll(resolved.whereType<ChannelRankItem>());
|
||||
}
|
||||
|
||||
result.sort(
|
||||
(a, b) {
|
||||
final views = b.personalViewCount.compareTo(a.personalViewCount);
|
||||
if (views != 0) {
|
||||
return views;
|
||||
}
|
||||
|
||||
final distinct =
|
||||
b.distinctVideoCount.compareTo(a.distinctVideoCount);
|
||||
if (distinct != 0) {
|
||||
return distinct;
|
||||
}
|
||||
|
||||
return a.channel.title
|
||||
.toLowerCase()
|
||||
.compareTo(b.channel.title.toLowerCase());
|
||||
},
|
||||
);
|
||||
|
||||
_cache = List.unmodifiable(result);
|
||||
_cacheFingerprint = fingerprint;
|
||||
_cacheAt = DateTime.now();
|
||||
|
||||
return _cache!;
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelAccumulator {
|
||||
_ChannelAccumulator({
|
||||
required this.channelId,
|
||||
required this.fallbackTitle,
|
||||
});
|
||||
|
||||
final String channelId;
|
||||
final String fallbackTitle;
|
||||
|
||||
SubscriptionChannel? channelOverride;
|
||||
|
||||
int personalViewCount = 0;
|
||||
int distinctVideoCount = 0;
|
||||
int topVideoPersonalViews = 0;
|
||||
int newestHistoryIndex = 1 << 30;
|
||||
String? topVideoTitle;
|
||||
|
||||
void add(
|
||||
VideoItem video, {
|
||||
required int personalViews,
|
||||
required int historyIndex,
|
||||
SubscriptionChannel? channelOverride,
|
||||
}) {
|
||||
if (channelOverride != null) {
|
||||
this.channelOverride = channelOverride;
|
||||
}
|
||||
|
||||
personalViewCount += personalViews;
|
||||
distinctVideoCount++;
|
||||
|
||||
if (historyIndex < newestHistoryIndex) {
|
||||
newestHistoryIndex = historyIndex;
|
||||
}
|
||||
|
||||
if (personalViews >= topVideoPersonalViews) {
|
||||
topVideoPersonalViews = personalViews;
|
||||
topVideoTitle = video.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../models/channel_details.dart';
|
||||
import 'youtube_service.dart';
|
||||
|
||||
class ChannelPreviewMetadataService {
|
||||
ChannelPreviewMetadataService._();
|
||||
|
||||
static final ChannelPreviewMetadataService instance =
|
||||
ChannelPreviewMetadataService._();
|
||||
|
||||
final YoutubeService _youtube = YoutubeService();
|
||||
|
||||
final Map<String, Future<ChannelDetails?>> _channelCache =
|
||||
<String, Future<ChannelDetails?>>{};
|
||||
|
||||
Future<ChannelDetails?> getChannel(
|
||||
String? channelId,
|
||||
) {
|
||||
final id = channelId?.trim() ?? '';
|
||||
|
||||
if (id.isEmpty) {
|
||||
return Future<ChannelDetails?>.value(null);
|
||||
}
|
||||
|
||||
return _channelCache.putIfAbsent(
|
||||
id,
|
||||
() => _youtube.getChannelPreviewDetails(id),
|
||||
);
|
||||
}
|
||||
|
||||
void invalidate(String channelId) {
|
||||
_channelCache.remove(channelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'google_oauth_config_service.dart';
|
||||
|
||||
class GoogleSession {
|
||||
const GoogleSession({
|
||||
required this.accessToken,
|
||||
required this.displayName,
|
||||
this.email,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String displayName;
|
||||
final String? email;
|
||||
final String? photoUrl;
|
||||
|
||||
GoogleSession copyWith({
|
||||
String? accessToken,
|
||||
String? displayName,
|
||||
String? email,
|
||||
String? photoUrl,
|
||||
}) {
|
||||
return GoogleSession(
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
displayName: displayName ?? this.displayName,
|
||||
email: email ?? this.email,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleAuthConfigurationException implements Exception {
|
||||
const GoogleAuthConfigurationException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class GoogleAuthService {
|
||||
GoogleAuthService._();
|
||||
|
||||
static final GoogleAuthService instance =
|
||||
GoogleAuthService._();
|
||||
|
||||
static const String youtubeReadonlyScope =
|
||||
'https://www.googleapis.com/auth/youtube.readonly';
|
||||
|
||||
static const String _envWebClientId =
|
||||
String.fromEnvironment(
|
||||
'GOOGLE_WEB_CLIENT_ID',
|
||||
);
|
||||
|
||||
static const String _envDesktopClientId =
|
||||
String.fromEnvironment(
|
||||
'GOOGLE_DESKTOP_CLIENT_ID',
|
||||
);
|
||||
|
||||
static const String _envDesktopClientSecret =
|
||||
String.fromEnvironment(
|
||||
'GOOGLE_DESKTOP_CLIENT_SECRET',
|
||||
);
|
||||
|
||||
final ValueNotifier<GoogleSession?> session =
|
||||
ValueNotifier<GoogleSession?>(null);
|
||||
|
||||
bool _googleSignInInitialized = false;
|
||||
String? _initializedAndroidWebClientId;
|
||||
GoogleSignInAccount? _androidAccount;
|
||||
|
||||
String? _windowsRefreshToken;
|
||||
DateTime? _windowsAccessTokenExpiry;
|
||||
|
||||
GoogleOauthConfig get _savedConfig =>
|
||||
GoogleOauthConfigService.instance.config.value;
|
||||
|
||||
String get _webClientId {
|
||||
final environment = _envWebClientId.trim();
|
||||
|
||||
if (environment.isNotEmpty) {
|
||||
return environment;
|
||||
}
|
||||
|
||||
return _savedConfig.webClientId.trim();
|
||||
}
|
||||
|
||||
String get _desktopClientId {
|
||||
final environment = _envDesktopClientId.trim();
|
||||
|
||||
if (environment.isNotEmpty) {
|
||||
return environment;
|
||||
}
|
||||
|
||||
return _savedConfig.desktopClientId.trim();
|
||||
}
|
||||
|
||||
String get _desktopClientSecret {
|
||||
final environment = _envDesktopClientSecret.trim();
|
||||
|
||||
if (environment.isNotEmpty) {
|
||||
return environment;
|
||||
}
|
||||
|
||||
return _savedConfig.desktopClientSecret.trim();
|
||||
}
|
||||
|
||||
bool get isConfigured {
|
||||
if (Platform.isAndroid) {
|
||||
return _webClientId.isNotEmpty;
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
return _desktopClientId.isNotEmpty;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
String get configurationHint {
|
||||
if (Platform.isAndroid) {
|
||||
return 'Client ID Web OAuth Google manquant';
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
return 'Client ID OAuth "Desktop app" manquant';
|
||||
}
|
||||
|
||||
return 'Plateforme non prise en charge';
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
await GoogleOauthConfigService.instance.initialize();
|
||||
|
||||
if (!Platform.isAndroid || !isConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _initializeAndroidGoogleSignIn();
|
||||
|
||||
try {
|
||||
final future =
|
||||
GoogleSignIn.instance
|
||||
.attemptLightweightAuthentication();
|
||||
|
||||
if (future == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final account = await future;
|
||||
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_androidAccount = account;
|
||||
|
||||
final authorization =
|
||||
await account.authorizationClient
|
||||
.authorizationForScopes(
|
||||
const [youtubeReadonlyScope],
|
||||
);
|
||||
|
||||
if (authorization == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.value = GoogleSession(
|
||||
accessToken: authorization.accessToken,
|
||||
displayName:
|
||||
account.displayName ?? account.email,
|
||||
email: account.email,
|
||||
photoUrl: account.photoUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[GOOGLE-AUTH] Restauration ignorée: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> saveAndroidConfiguration(
|
||||
String webClientId,
|
||||
) async {
|
||||
final previous = _webClientId;
|
||||
|
||||
await GoogleOauthConfigService.instance.saveAndroid(
|
||||
webClientId: webClientId,
|
||||
);
|
||||
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final next = _webClientId;
|
||||
|
||||
if (next.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_googleSignInInitialized) {
|
||||
await _initializeAndroidGoogleSignIn();
|
||||
return true;
|
||||
}
|
||||
|
||||
return previous == next;
|
||||
}
|
||||
|
||||
Future<void> saveWindowsConfiguration({
|
||||
required String desktopClientId,
|
||||
required String desktopClientSecret,
|
||||
}) async {
|
||||
await GoogleOauthConfigService.instance.saveWindows(
|
||||
desktopClientId: desktopClientId,
|
||||
desktopClientSecret: desktopClientSecret,
|
||||
);
|
||||
}
|
||||
|
||||
Future<GoogleSession> signIn() async {
|
||||
if (!isConfigured) {
|
||||
throw GoogleAuthConfigurationException(
|
||||
configurationHint,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
return _signInAndroid();
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
return _signInWindows();
|
||||
}
|
||||
|
||||
throw const GoogleAuthConfigurationException(
|
||||
'Connexion Google non prise en charge '
|
||||
'sur cette plateforme.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> getAccessToken() async {
|
||||
final current = session.value;
|
||||
|
||||
if (current == null) {
|
||||
throw StateError(
|
||||
'Aucune session Google active.',
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final account = _androidAccount;
|
||||
|
||||
if (account != null) {
|
||||
final authorization =
|
||||
await account.authorizationClient
|
||||
.authorizationForScopes(
|
||||
const [youtubeReadonlyScope],
|
||||
);
|
||||
|
||||
if (authorization != null) {
|
||||
final nextAccessToken =
|
||||
authorization.accessToken;
|
||||
|
||||
// Ne notifie les listeners que si le jeton a réellement
|
||||
// changé. Avant, chaque simple lecture du jeton réémettait
|
||||
// session.value et pouvait relancer le chargement des
|
||||
// abonnements en boucle.
|
||||
if (nextAccessToken != current.accessToken) {
|
||||
session.value = current.copyWith(
|
||||
accessToken: nextAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
return nextAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
return current.accessToken;
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
final expiry = _windowsAccessTokenExpiry;
|
||||
|
||||
if (expiry != null &&
|
||||
DateTime.now().isAfter(
|
||||
expiry.subtract(
|
||||
const Duration(minutes: 2),
|
||||
),
|
||||
) &&
|
||||
_windowsRefreshToken != null) {
|
||||
await _refreshWindowsAccessToken();
|
||||
}
|
||||
|
||||
return session.value?.accessToken ??
|
||||
current.accessToken;
|
||||
}
|
||||
|
||||
return current.accessToken;
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
if (Platform.isAndroid &&
|
||||
_googleSignInInitialized) {
|
||||
try {
|
||||
await GoogleSignIn.instance.signOut();
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[GOOGLE-AUTH] signOut Android: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_androidAccount = null;
|
||||
_windowsRefreshToken = null;
|
||||
_windowsAccessTokenExpiry = null;
|
||||
session.value = null;
|
||||
}
|
||||
|
||||
Future<void> _initializeAndroidGoogleSignIn() async {
|
||||
if (_googleSignInInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
final clientId = _webClientId;
|
||||
|
||||
if (clientId.isEmpty) {
|
||||
throw const GoogleAuthConfigurationException(
|
||||
'Client ID Web OAuth Google manquant.',
|
||||
);
|
||||
}
|
||||
|
||||
await GoogleSignIn.instance.initialize(
|
||||
serverClientId: clientId,
|
||||
);
|
||||
|
||||
_googleSignInInitialized = true;
|
||||
_initializedAndroidWebClientId = clientId;
|
||||
}
|
||||
|
||||
Future<GoogleSession> _signInAndroid() async {
|
||||
await _initializeAndroidGoogleSignIn();
|
||||
|
||||
if (_initializedAndroidWebClientId !=
|
||||
_webClientId) {
|
||||
throw const GoogleAuthConfigurationException(
|
||||
'Le Client ID Android a été modifié. '
|
||||
'Redémarre l’application avant de te connecter.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!GoogleSignIn.instance.supportsAuthenticate()) {
|
||||
throw StateError(
|
||||
'Google Sign-In interactif '
|
||||
'n’est pas disponible.',
|
||||
);
|
||||
}
|
||||
|
||||
final account =
|
||||
await GoogleSignIn.instance.authenticate(
|
||||
scopeHint: const [youtubeReadonlyScope],
|
||||
);
|
||||
|
||||
_androidAccount = account;
|
||||
|
||||
var authorization =
|
||||
await account.authorizationClient
|
||||
.authorizationForScopes(
|
||||
const [youtubeReadonlyScope],
|
||||
);
|
||||
|
||||
authorization ??=
|
||||
await account.authorizationClient
|
||||
.authorizeScopes(
|
||||
const [youtubeReadonlyScope],
|
||||
);
|
||||
|
||||
final result = GoogleSession(
|
||||
accessToken: authorization.accessToken,
|
||||
displayName:
|
||||
account.displayName ?? account.email,
|
||||
email: account.email,
|
||||
photoUrl: account.photoUrl,
|
||||
);
|
||||
|
||||
session.value = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<GoogleSession> _signInWindows() async {
|
||||
final server = await HttpServer.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
|
||||
final redirectUri = Uri.parse(
|
||||
'http://127.0.0.1:${server.port}/',
|
||||
);
|
||||
|
||||
final verifier = _randomUrlSafeString(96);
|
||||
final state = _randomUrlSafeString(32);
|
||||
|
||||
final challenge = base64UrlEncode(
|
||||
sha256.convert(ascii.encode(verifier)).bytes,
|
||||
).replaceAll('=', '');
|
||||
|
||||
const windowsScopes = <String>[
|
||||
'openid',
|
||||
'email',
|
||||
'profile',
|
||||
youtubeReadonlyScope,
|
||||
];
|
||||
|
||||
final authorizationUri = Uri.https(
|
||||
'accounts.google.com',
|
||||
'/o/oauth2/v2/auth',
|
||||
{
|
||||
'client_id': _desktopClientId,
|
||||
'redirect_uri': redirectUri.toString(),
|
||||
'response_type': 'code',
|
||||
'scope': windowsScopes.join(' '),
|
||||
'code_challenge': challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'consent',
|
||||
},
|
||||
);
|
||||
|
||||
final launched = await launchUrl(
|
||||
authorizationUri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
if (!launched) {
|
||||
await server.close(force: true);
|
||||
|
||||
throw StateError(
|
||||
'Impossible d’ouvrir le navigateur '
|
||||
'pour Google.',
|
||||
);
|
||||
}
|
||||
|
||||
HttpRequest request;
|
||||
|
||||
try {
|
||||
request = await server.first.timeout(
|
||||
const Duration(minutes: 3),
|
||||
);
|
||||
} on TimeoutException {
|
||||
await server.close(force: true);
|
||||
|
||||
throw StateError(
|
||||
'La connexion Google a expiré.',
|
||||
);
|
||||
}
|
||||
|
||||
final params = request.uri.queryParameters;
|
||||
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.html
|
||||
..write(
|
||||
'<!doctype html><html><head>'
|
||||
'<meta charset="utf-8">'
|
||||
'<title>YouTube Personal</title></head>'
|
||||
'<body style="font-family:Segoe UI,Arial;'
|
||||
'padding:40px;background:#111;color:#fff">'
|
||||
'<h2>Connexion terminée</h2>'
|
||||
'<p>Tu peux fermer cet onglet et revenir '
|
||||
'dans YouTube Personal.</p></body></html>',
|
||||
);
|
||||
|
||||
await request.response.close();
|
||||
await server.close(force: true);
|
||||
|
||||
if (params['state'] != state) {
|
||||
throw StateError(
|
||||
'Réponse OAuth Google invalide '
|
||||
'(state incorrect).',
|
||||
);
|
||||
}
|
||||
|
||||
final error = params['error'];
|
||||
|
||||
if (error != null) {
|
||||
throw StateError(
|
||||
'Connexion Google refusée: $error',
|
||||
);
|
||||
}
|
||||
|
||||
final code = params['code'];
|
||||
|
||||
if (code == null || code.isEmpty) {
|
||||
throw StateError(
|
||||
'Google n’a pas renvoyé de '
|
||||
'code d’autorisation.',
|
||||
);
|
||||
}
|
||||
|
||||
final tokenBody = <String, String>{
|
||||
'client_id': _desktopClientId,
|
||||
'code': code,
|
||||
'code_verifier': verifier,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': redirectUri.toString(),
|
||||
};
|
||||
|
||||
if (_desktopClientSecret.isNotEmpty) {
|
||||
tokenBody['client_secret'] =
|
||||
_desktopClientSecret;
|
||||
}
|
||||
|
||||
final tokenResponse = await http.post(
|
||||
Uri.parse(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
),
|
||||
headers: const {
|
||||
'Content-Type':
|
||||
'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: tokenBody,
|
||||
);
|
||||
|
||||
if (tokenResponse.statusCode < 200 ||
|
||||
tokenResponse.statusCode >= 300) {
|
||||
throw StateError(
|
||||
'Échange OAuth Google impossible '
|
||||
'(${tokenResponse.statusCode}): '
|
||||
'${tokenResponse.body}',
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(
|
||||
tokenResponse.body,
|
||||
);
|
||||
|
||||
if (decoded is! Map) {
|
||||
throw StateError(
|
||||
'Réponse OAuth Google invalide.',
|
||||
);
|
||||
}
|
||||
|
||||
final accessToken = decoded['access_token'];
|
||||
|
||||
if (accessToken is! String ||
|
||||
accessToken.isEmpty) {
|
||||
throw StateError(
|
||||
'Aucun jeton d’accès Google reçu.',
|
||||
);
|
||||
}
|
||||
|
||||
final refreshToken = decoded['refresh_token'];
|
||||
|
||||
if (refreshToken is String &&
|
||||
refreshToken.isNotEmpty) {
|
||||
_windowsRefreshToken = refreshToken;
|
||||
}
|
||||
|
||||
final expiresIn = decoded['expires_in'];
|
||||
|
||||
if (expiresIn is num) {
|
||||
_windowsAccessTokenExpiry =
|
||||
DateTime.now().add(
|
||||
Duration(
|
||||
seconds: expiresIn.toInt(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final profile = await _fetchGoogleProfile(
|
||||
accessToken,
|
||||
);
|
||||
|
||||
final result = GoogleSession(
|
||||
accessToken: accessToken,
|
||||
displayName:
|
||||
profile.name ??
|
||||
profile.email ??
|
||||
'Compte Google',
|
||||
email: profile.email,
|
||||
photoUrl: profile.photoUrl,
|
||||
);
|
||||
|
||||
session.value = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _refreshWindowsAccessToken() async {
|
||||
final refreshToken = _windowsRefreshToken;
|
||||
|
||||
if (refreshToken == null ||
|
||||
refreshToken.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final body = <String, String>{
|
||||
'client_id': _desktopClientId,
|
||||
'refresh_token': refreshToken,
|
||||
'grant_type': 'refresh_token',
|
||||
};
|
||||
|
||||
if (_desktopClientSecret.isNotEmpty) {
|
||||
body['client_secret'] =
|
||||
_desktopClientSecret;
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
),
|
||||
headers: const {
|
||||
'Content-Type':
|
||||
'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body,
|
||||
);
|
||||
|
||||
if (response.statusCode < 200 ||
|
||||
response.statusCode >= 300) {
|
||||
throw StateError(
|
||||
'Impossible de renouveler '
|
||||
'la session Google.',
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
||||
if (decoded is! Map ||
|
||||
decoded['access_token'] is! String) {
|
||||
throw StateError(
|
||||
'Réponse de renouvellement '
|
||||
'Google invalide.',
|
||||
);
|
||||
}
|
||||
|
||||
final token =
|
||||
decoded['access_token'] as String;
|
||||
|
||||
final expiresIn = decoded['expires_in'];
|
||||
|
||||
if (expiresIn is num) {
|
||||
_windowsAccessTokenExpiry =
|
||||
DateTime.now().add(
|
||||
Duration(
|
||||
seconds: expiresIn.toInt(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final current = session.value;
|
||||
|
||||
if (current != null) {
|
||||
session.value = current.copyWith(
|
||||
accessToken: token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<_GoogleProfile> _fetchGoogleProfile(
|
||||
String accessToken,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'https://www.googleapis.com/oauth2/v3/userinfo',
|
||||
),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode < 200 ||
|
||||
response.statusCode >= 300) {
|
||||
return const _GoogleProfile();
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
||||
if (decoded is! Map) {
|
||||
return const _GoogleProfile();
|
||||
}
|
||||
|
||||
return _GoogleProfile(
|
||||
name: decoded['name'] is String
|
||||
? decoded['name'] as String
|
||||
: null,
|
||||
email: decoded['email'] is String
|
||||
? decoded['email'] as String
|
||||
: null,
|
||||
photoUrl: decoded['picture'] is String
|
||||
? decoded['picture'] as String
|
||||
: null,
|
||||
);
|
||||
} catch (_) {
|
||||
return const _GoogleProfile();
|
||||
}
|
||||
}
|
||||
|
||||
String _randomUrlSafeString(int length) {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
'abcdefghijklmnopqrstuvwxyz'
|
||||
'0123456789-._~';
|
||||
|
||||
final random = Random.secure();
|
||||
|
||||
return List.generate(
|
||||
length,
|
||||
(_) => chars[
|
||||
random.nextInt(chars.length)],
|
||||
).join();
|
||||
}
|
||||
}
|
||||
|
||||
class _GoogleProfile {
|
||||
const _GoogleProfile({
|
||||
this.name,
|
||||
this.email,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
final String? name;
|
||||
final String? email;
|
||||
final String? photoUrl;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class GoogleOauthConfig {
|
||||
const GoogleOauthConfig({
|
||||
this.webClientId = '',
|
||||
this.desktopClientId = '',
|
||||
this.desktopClientSecret = '',
|
||||
});
|
||||
|
||||
final String webClientId;
|
||||
final String desktopClientId;
|
||||
final String desktopClientSecret;
|
||||
|
||||
bool get hasAndroidConfig => webClientId.trim().isNotEmpty;
|
||||
bool get hasWindowsConfig => desktopClientId.trim().isNotEmpty;
|
||||
|
||||
GoogleOauthConfig copyWith({
|
||||
String? webClientId,
|
||||
String? desktopClientId,
|
||||
String? desktopClientSecret,
|
||||
}) {
|
||||
return GoogleOauthConfig(
|
||||
webClientId: webClientId ?? this.webClientId,
|
||||
desktopClientId:
|
||||
desktopClientId ?? this.desktopClientId,
|
||||
desktopClientSecret:
|
||||
desktopClientSecret ?? this.desktopClientSecret,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleOauthConfigService {
|
||||
GoogleOauthConfigService._();
|
||||
|
||||
static final GoogleOauthConfigService instance =
|
||||
GoogleOauthConfigService._();
|
||||
|
||||
static const String _webClientIdKey =
|
||||
'youtube_personal.google.web_client_id.v1';
|
||||
static const String _desktopClientIdKey =
|
||||
'youtube_personal.google.desktop_client_id.v1';
|
||||
static const String _desktopClientSecretKey =
|
||||
'youtube_personal.google.desktop_client_secret.v1';
|
||||
|
||||
final SharedPreferencesAsync _preferences =
|
||||
SharedPreferencesAsync();
|
||||
|
||||
final ValueNotifier<GoogleOauthConfig> config =
|
||||
ValueNotifier<GoogleOauthConfig>(
|
||||
const GoogleOauthConfig(),
|
||||
);
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
final webClientId =
|
||||
await _preferences.getString(_webClientIdKey) ?? '';
|
||||
final desktopClientId =
|
||||
await _preferences.getString(_desktopClientIdKey) ?? '';
|
||||
final desktopClientSecret =
|
||||
await _preferences.getString(
|
||||
_desktopClientSecretKey,
|
||||
) ??
|
||||
'';
|
||||
|
||||
config.value = GoogleOauthConfig(
|
||||
webClientId: webClientId,
|
||||
desktopClientId: desktopClientId,
|
||||
desktopClientSecret: desktopClientSecret,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> saveAndroid({
|
||||
required String webClientId,
|
||||
}) async {
|
||||
final value = webClientId.trim();
|
||||
|
||||
if (value.isEmpty) {
|
||||
await _preferences.remove(_webClientIdKey);
|
||||
} else {
|
||||
await _preferences.setString(
|
||||
_webClientIdKey,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
config.value = config.value.copyWith(
|
||||
webClientId: value,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveWindows({
|
||||
required String desktopClientId,
|
||||
required String desktopClientSecret,
|
||||
}) async {
|
||||
final clientId = desktopClientId.trim();
|
||||
final clientSecret = desktopClientSecret.trim();
|
||||
|
||||
if (clientId.isEmpty) {
|
||||
await _preferences.remove(_desktopClientIdKey);
|
||||
} else {
|
||||
await _preferences.setString(
|
||||
_desktopClientIdKey,
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
if (clientSecret.isEmpty) {
|
||||
await _preferences.remove(
|
||||
_desktopClientSecretKey,
|
||||
);
|
||||
} else {
|
||||
await _preferences.setString(
|
||||
_desktopClientSecretKey,
|
||||
clientSecret,
|
||||
);
|
||||
}
|
||||
|
||||
config.value = config.value.copyWith(
|
||||
desktopClientId: clientId,
|
||||
desktopClientSecret: clientSecret,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/subscription_channel.dart';
|
||||
import '../models/video_item.dart';
|
||||
|
||||
class HiddenChannelsService {
|
||||
HiddenChannelsService._();
|
||||
|
||||
static final HiddenChannelsService instance =
|
||||
HiddenChannelsService._();
|
||||
|
||||
static const String _key =
|
||||
'youtube_personal.hidden_channels.v1';
|
||||
|
||||
final SharedPreferencesAsync _preferences =
|
||||
SharedPreferencesAsync();
|
||||
|
||||
final Map<String, String> _hiddenById = <String, String>{};
|
||||
final Set<String> _hiddenTitles = <String>{};
|
||||
|
||||
bool _loaded = false;
|
||||
|
||||
/// Lets Home/Channels invalidate their caches when the user hides or
|
||||
/// restores a channel.
|
||||
final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
Future<void> ensureLoaded() async {
|
||||
if (_loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
|
||||
final raw = await _preferences.getString(_key);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final item in decoded) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = item['id'];
|
||||
final title = item['title'];
|
||||
|
||||
if (id is String && id.trim().isNotEmpty) {
|
||||
_hiddenById[id.trim()] = title is String ? title : '';
|
||||
}
|
||||
|
||||
if (title is String && title.trim().isNotEmpty) {
|
||||
_hiddenTitles.add(_normalize(title));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
_hiddenById.clear();
|
||||
_hiddenTitles.clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool isHiddenChannel({
|
||||
String? channelId,
|
||||
String? title,
|
||||
}) {
|
||||
final id = channelId?.trim() ?? '';
|
||||
if (id.isNotEmpty && _hiddenById.containsKey(id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final normalized = _normalize(title ?? '');
|
||||
return normalized.isNotEmpty && _hiddenTitles.contains(normalized);
|
||||
}
|
||||
|
||||
bool isHiddenVideo(VideoItem video) {
|
||||
return isHiddenChannel(
|
||||
channelId: video.channelId,
|
||||
title: video.author,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> hide(SubscriptionChannel channel) async {
|
||||
await ensureLoaded();
|
||||
|
||||
_hiddenById[channel.channelId] = channel.title;
|
||||
|
||||
final normalized = _normalize(channel.title);
|
||||
if (normalized.isNotEmpty) {
|
||||
_hiddenTitles.add(normalized);
|
||||
}
|
||||
|
||||
await _save();
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
Future<void> restore(SubscriptionChannel channel) async {
|
||||
await ensureLoaded();
|
||||
|
||||
_hiddenById.remove(channel.channelId);
|
||||
|
||||
final normalized = _normalize(channel.title);
|
||||
if (normalized.isNotEmpty) {
|
||||
// Keep a title hidden only if another hidden channel still uses it.
|
||||
final stillUsed = _hiddenById.values.any(
|
||||
(value) => _normalize(value) == normalized,
|
||||
);
|
||||
|
||||
if (!stillUsed) {
|
||||
_hiddenTitles.remove(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
await _save();
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final payload = <Map<String, String>>[
|
||||
for (final entry in _hiddenById.entries)
|
||||
<String, String>{
|
||||
'id': entry.key,
|
||||
'title': entry.value,
|
||||
},
|
||||
];
|
||||
|
||||
await _preferences.setString(
|
||||
_key,
|
||||
jsonEncode(payload),
|
||||
);
|
||||
}
|
||||
|
||||
String _normalize(String value) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
|
||||
class LibraryService {
|
||||
LibraryService._();
|
||||
|
||||
static final LibraryService instance = LibraryService._();
|
||||
|
||||
static const String _historyKey = 'youtube_personal.history.v1';
|
||||
static const String _favoritesKey = 'youtube_personal.favorites.v1';
|
||||
static const String _positionsKey = 'youtube_personal.positions.v1';
|
||||
static const String _watchCountsKey =
|
||||
'youtube_personal.watch_counts.v1';
|
||||
static const int _maxHistoryItems = 100;
|
||||
|
||||
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
|
||||
|
||||
final ValueNotifier<List<VideoItem>> history =
|
||||
ValueNotifier<List<VideoItem>>(const []);
|
||||
|
||||
final ValueNotifier<List<VideoItem>> favorites =
|
||||
ValueNotifier<List<VideoItem>>(const []);
|
||||
|
||||
final Map<String, int> _positionsMs = <String, int>{};
|
||||
final Map<String, int> _watchCounts = <String, int>{};
|
||||
|
||||
/// Changes whenever a real video opening increments the user's own view
|
||||
/// counter. The Channels page uses this to refresh its ranking.
|
||||
final ValueNotifier<int> watchRevision = ValueNotifier<int>(0);
|
||||
|
||||
Future<void> initialize() async {
|
||||
history.value = await _readList(_historyKey);
|
||||
favorites.value = await _readList(_favoritesKey);
|
||||
|
||||
_positionsMs
|
||||
..clear()
|
||||
..addAll(await _readPositions());
|
||||
|
||||
_watchCounts
|
||||
..clear()
|
||||
..addAll(await _readIntMap(_watchCountsKey));
|
||||
|
||||
// Migration for installs created before personal watch counters existed.
|
||||
// Every video already present in history counts as at least one personal
|
||||
// view, without inventing repeat views that we never recorded.
|
||||
var migrated = false;
|
||||
for (final video in history.value) {
|
||||
if ((_watchCounts[video.id] ?? 0) <= 0) {
|
||||
_watchCounts[video.id] = 1;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
await _writeWatchCounts();
|
||||
}
|
||||
}
|
||||
|
||||
int watchCountForVideo(String videoId) {
|
||||
return _watchCounts[videoId] ?? 0;
|
||||
}
|
||||
|
||||
Future<void> addToHistory(
|
||||
VideoItem video, {
|
||||
bool incrementWatchCount = true,
|
||||
}) async {
|
||||
final updated = <VideoItem>[
|
||||
video,
|
||||
...history.value.where((item) => item.id != video.id),
|
||||
];
|
||||
|
||||
if (updated.length > _maxHistoryItems) {
|
||||
updated.removeRange(_maxHistoryItems, updated.length);
|
||||
}
|
||||
|
||||
history.value = List<VideoItem>.unmodifiable(updated);
|
||||
await _writeList(_historyKey, history.value);
|
||||
|
||||
if (incrementWatchCount) {
|
||||
_watchCounts.update(
|
||||
video.id,
|
||||
(value) => value + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
|
||||
await _writeWatchCounts();
|
||||
watchRevision.value++;
|
||||
}
|
||||
}
|
||||
|
||||
bool isFavorite(String videoId) {
|
||||
return favorites.value.any((item) => item.id == videoId);
|
||||
}
|
||||
|
||||
Future<bool> toggleFavorite(VideoItem video) async {
|
||||
final updated = [...favorites.value];
|
||||
final index = updated.indexWhere((item) => item.id == video.id);
|
||||
|
||||
if (index >= 0) {
|
||||
updated.removeAt(index);
|
||||
} else {
|
||||
updated.insert(0, video);
|
||||
}
|
||||
|
||||
favorites.value = List<VideoItem>.unmodifiable(updated);
|
||||
await _writeList(_favoritesKey, favorites.value);
|
||||
|
||||
return isFavorite(video.id);
|
||||
}
|
||||
|
||||
Duration? getPlaybackPosition(String videoId) {
|
||||
final milliseconds = _positionsMs[videoId];
|
||||
|
||||
if (milliseconds == null || milliseconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Duration(milliseconds: milliseconds);
|
||||
}
|
||||
|
||||
Future<void> savePlaybackPosition(
|
||||
String videoId,
|
||||
Duration position, {
|
||||
Duration? duration,
|
||||
}) async {
|
||||
final positionMs = position.inMilliseconds;
|
||||
|
||||
// On ne mémorise pas les toutes premières secondes.
|
||||
if (positionMs < const Duration(seconds: 5).inMilliseconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si la vidéo est pratiquement terminée, on efface la reprise.
|
||||
if (duration != null && duration > Duration.zero) {
|
||||
final remaining = duration - position;
|
||||
|
||||
if (remaining <= const Duration(seconds: 15)) {
|
||||
await clearPlaybackPosition(videoId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_positionsMs[videoId] = positionMs;
|
||||
await _writePositions();
|
||||
}
|
||||
|
||||
Future<void> clearPlaybackPosition(String videoId) async {
|
||||
if (_positionsMs.remove(videoId) != null) {
|
||||
await _writePositions();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeFromHistory(String videoId) async {
|
||||
final updated = history.value.where((item) => item.id != videoId).toList();
|
||||
history.value = List<VideoItem>.unmodifiable(updated);
|
||||
await _writeList(_historyKey, history.value);
|
||||
}
|
||||
|
||||
Future<void> clearHistory() async {
|
||||
history.value = const [];
|
||||
await _preferences.remove(_historyKey);
|
||||
}
|
||||
|
||||
Future<void> clearFavorites() async {
|
||||
favorites.value = const [];
|
||||
await _preferences.remove(_favoritesKey);
|
||||
}
|
||||
|
||||
Future<List<VideoItem>> _readList(String key) async {
|
||||
final raw = await _preferences.getString(key);
|
||||
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
|
||||
if (decoded is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => VideoItem.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, int>> _readPositions() {
|
||||
return _readIntMap(_positionsKey);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> _readIntMap(String key) async {
|
||||
final raw = await _preferences.getString(key);
|
||||
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
|
||||
if (decoded is! Map) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, int>{};
|
||||
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
|
||||
if (entry.key is String && value is num) {
|
||||
result[entry.key as String] = value.toInt();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeList(
|
||||
String key,
|
||||
List<VideoItem> videos,
|
||||
) async {
|
||||
final payload = jsonEncode(
|
||||
videos.map((video) => video.toJson()).toList(growable: false),
|
||||
);
|
||||
|
||||
await _preferences.setString(key, payload);
|
||||
}
|
||||
|
||||
Future<void> _writePositions() async {
|
||||
await _preferences.setString(
|
||||
_positionsKey,
|
||||
jsonEncode(_positionsMs),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _writeWatchCounts() async {
|
||||
await _preferences.setString(
|
||||
_watchCountsKey,
|
||||
jsonEncode(_watchCounts),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MediaCacheSettingsService {
|
||||
MediaCacheSettingsService._();
|
||||
|
||||
static final MediaCacheSettingsService instance =
|
||||
MediaCacheSettingsService._();
|
||||
|
||||
static const String _customPathKey =
|
||||
'youtube_personal.cache.custom_path.v1';
|
||||
static const String _maxCacheGbKey =
|
||||
'youtube_personal.cache.max_gb.v1';
|
||||
static const String _androidBufferMbKey =
|
||||
'youtube_personal.cache.android_buffer_mb.v1';
|
||||
static const String _androidDiskCacheModeKey =
|
||||
'youtube_personal.cache.android_disk_mode.v1';
|
||||
|
||||
static const int defaultMaxCacheGb = 5;
|
||||
static const List<int> supportedMaxCacheGb = <int>[
|
||||
2,
|
||||
5,
|
||||
10,
|
||||
20,
|
||||
50,
|
||||
0,
|
||||
];
|
||||
|
||||
// Android : le flux vidéo direct reste par défaut en cache mémoire.
|
||||
// 64 Mo offre une marge confortable pour 1080p/1440p/4K sans créer
|
||||
// de cache disque persistant.
|
||||
static const int defaultAndroidBufferMb = 64;
|
||||
static const List<int> supportedAndroidBufferMb = <int>[
|
||||
32,
|
||||
64,
|
||||
128,
|
||||
];
|
||||
|
||||
// 0 = RAM uniquement, 1 = cache temporaire stockage interne app,
|
||||
// 2 = cache temporaire stockage externe app.
|
||||
static const int androidDiskCacheDisabled = 0;
|
||||
static const int androidDiskCacheInternal = 1;
|
||||
static const int androidDiskCacheExternal = 2;
|
||||
static const List<int> supportedAndroidDiskCacheModes = <int>[
|
||||
androidDiskCacheDisabled,
|
||||
androidDiskCacheInternal,
|
||||
androidDiskCacheExternal,
|
||||
];
|
||||
|
||||
static const MethodChannel _androidCacheChannel = MethodChannel(
|
||||
'com.studioweb.youtube_personal/cache',
|
||||
);
|
||||
|
||||
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
|
||||
|
||||
final ValueNotifier<String> customCachePath =
|
||||
ValueNotifier<String>('');
|
||||
|
||||
final ValueNotifier<int> maxCacheGb =
|
||||
ValueNotifier<int>(defaultMaxCacheGb);
|
||||
|
||||
final ValueNotifier<int> androidBufferMb =
|
||||
ValueNotifier<int>(defaultAndroidBufferMb);
|
||||
|
||||
final ValueNotifier<int> androidDiskCacheMode =
|
||||
ValueNotifier<int>(androidDiskCacheDisabled);
|
||||
|
||||
Future<void>? _initialization;
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> ensureInitialized() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
_initialization ??= _load();
|
||||
await _initialization;
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final storedPath = await _preferences.getString(_customPathKey);
|
||||
final storedMaxGb = await _preferences.getInt(_maxCacheGbKey);
|
||||
final storedAndroidBufferMb =
|
||||
await _preferences.getInt(_androidBufferMbKey);
|
||||
final storedAndroidDiskCacheMode =
|
||||
await _preferences.getInt(_androidDiskCacheModeKey);
|
||||
|
||||
if (storedPath != null && storedPath.trim().isNotEmpty) {
|
||||
customCachePath.value = storedPath.trim();
|
||||
}
|
||||
|
||||
if (storedMaxGb != null && supportedMaxCacheGb.contains(storedMaxGb)) {
|
||||
maxCacheGb.value = storedMaxGb;
|
||||
}
|
||||
|
||||
if (storedAndroidBufferMb != null &&
|
||||
supportedAndroidBufferMb.contains(storedAndroidBufferMb)) {
|
||||
androidBufferMb.value = storedAndroidBufferMb;
|
||||
}
|
||||
|
||||
if (storedAndroidDiskCacheMode != null &&
|
||||
supportedAndroidDiskCacheModes.contains(storedAndroidDiskCacheMode)) {
|
||||
androidDiskCacheMode.value = storedAndroidDiskCacheMode;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
|
||||
// Nettoyage immédiat au premier lancement après mise à jour : l'ancien
|
||||
// cache Windows de plusieurs dizaines de Go est ramené à la limite
|
||||
// configurée avant même d'ouvrir une nouvelle vidéo.
|
||||
if (Platform.isWindows) {
|
||||
await enforceLimit();
|
||||
}
|
||||
}
|
||||
|
||||
String get defaultCachePath {
|
||||
final localAppData = Platform.environment['LOCALAPPDATA'];
|
||||
final rootPath = localAppData != null && localAppData.trim().isNotEmpty
|
||||
? localAppData.trim()
|
||||
: Directory.systemTemp.path;
|
||||
|
||||
return '$rootPath'
|
||||
'${Platform.pathSeparator}'
|
||||
'YouTubePersonal'
|
||||
'${Platform.pathSeparator}'
|
||||
'media_cache';
|
||||
}
|
||||
|
||||
Future<String> effectiveCachePath() async {
|
||||
await ensureInitialized();
|
||||
|
||||
final custom = customCachePath.value.trim();
|
||||
if (custom.isNotEmpty) {
|
||||
return custom;
|
||||
}
|
||||
|
||||
return defaultCachePath;
|
||||
}
|
||||
|
||||
Future<Directory> resolveCacheRoot() async {
|
||||
final path = await effectiveCachePath();
|
||||
final directory = Directory(path);
|
||||
|
||||
try {
|
||||
await directory.create(recursive: true);
|
||||
return directory;
|
||||
} catch (error) {
|
||||
// Si un disque externe / secondaire a disparu, ne pas casser le
|
||||
// lecteur Windows : retour temporaire au chemin par défaut.
|
||||
if (customCachePath.value.trim().isNotEmpty) {
|
||||
debugPrint(
|
||||
'[MEDIA-CACHE][18.7.15] chemin personnalisé indisponible '
|
||||
'$path -> fallback: $error',
|
||||
);
|
||||
final fallback = Directory(defaultCachePath);
|
||||
await fallback.create(recursive: true);
|
||||
return fallback;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setCustomCachePath(String value) async {
|
||||
await ensureInitialized();
|
||||
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || _samePath(trimmed, defaultCachePath)) {
|
||||
customCachePath.value = '';
|
||||
await _preferences.remove(_customPathKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final directory = Directory(trimmed);
|
||||
await directory.create(recursive: true);
|
||||
|
||||
final testFile = File(
|
||||
'${directory.path}${Platform.pathSeparator}.youtube_personal_write_test',
|
||||
);
|
||||
|
||||
try {
|
||||
await testFile.writeAsString('ok', flush: true);
|
||||
} finally {
|
||||
try {
|
||||
if (await testFile.exists()) {
|
||||
await testFile.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Non bloquant.
|
||||
}
|
||||
}
|
||||
|
||||
customCachePath.value = directory.path;
|
||||
await _preferences.setString(_customPathKey, directory.path);
|
||||
}
|
||||
|
||||
Future<void> resetCustomCachePath() async {
|
||||
await setCustomCachePath('');
|
||||
}
|
||||
|
||||
Future<void> setMaxCacheGb(int value) async {
|
||||
await ensureInitialized();
|
||||
|
||||
if (!supportedMaxCacheGb.contains(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
maxCacheGb.value = value;
|
||||
await _preferences.setInt(_maxCacheGbKey, value);
|
||||
|
||||
if (Platform.isWindows) {
|
||||
await enforceLimit();
|
||||
}
|
||||
}
|
||||
|
||||
static String maxCacheLabel(int value) {
|
||||
if (value == 0) {
|
||||
return 'Illimité';
|
||||
}
|
||||
return '$value Go';
|
||||
}
|
||||
|
||||
Future<void> setAndroidBufferMb(int value) async {
|
||||
await ensureInitialized();
|
||||
|
||||
if (!supportedAndroidBufferMb.contains(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
androidBufferMb.value = value;
|
||||
await _preferences.setInt(_androidBufferMbKey, value);
|
||||
}
|
||||
|
||||
static String androidBufferLabel(int value) => '$value Mo';
|
||||
|
||||
Future<void> setAndroidDiskCacheMode(int value) async {
|
||||
await ensureInitialized();
|
||||
|
||||
if (!supportedAndroidDiskCacheModes.contains(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
androidDiskCacheMode.value = value;
|
||||
await _preferences.setInt(_androidDiskCacheModeKey, value);
|
||||
|
||||
if (!Platform.isAndroid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Quand le cache disque est désactivé, supprimer les éventuels restes
|
||||
// d'anciennes sessions sur les deux emplacements.
|
||||
if (value == androidDiskCacheDisabled) {
|
||||
await clearAndroidMediaCache(androidDiskCacheInternal);
|
||||
await clearAndroidMediaCache(androidDiskCacheExternal);
|
||||
} else {
|
||||
// Le dossier est temporaire. On retire les restes d'un éventuel crash
|
||||
// précédent avant la prochaine lecture.
|
||||
await clearAndroidMediaCache(value);
|
||||
}
|
||||
}
|
||||
|
||||
static String androidDiskCacheModeLabel(int value) {
|
||||
return switch (value) {
|
||||
androidDiskCacheInternal => 'Interne (app)',
|
||||
androidDiskCacheExternal => 'Externe (app)',
|
||||
_ => 'Désactivé (RAM uniquement)',
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, String>> androidCachePaths() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return const <String, String>{};
|
||||
}
|
||||
|
||||
try {
|
||||
final raw = await _androidCacheChannel.invokeMapMethod<String, dynamic>(
|
||||
'getCachePaths',
|
||||
);
|
||||
|
||||
if (raw == null) {
|
||||
return const <String, String>{};
|
||||
}
|
||||
|
||||
final result = <String, String>{};
|
||||
for (final entry in raw.entries) {
|
||||
final value = entry.value;
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
result[entry.key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
debugPrint('[MEDIA-CACHE][18.7.15] Android paths: $error');
|
||||
return const <String, String>{};
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> androidCachePathForMode(int mode) async {
|
||||
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final paths = await androidCachePaths();
|
||||
|
||||
if (mode == androidDiskCacheExternal) {
|
||||
return paths['external'] ?? paths['internal'] ?? '';
|
||||
}
|
||||
|
||||
return paths['internal'] ?? '';
|
||||
}
|
||||
|
||||
Future<int> androidCacheSizeBytes(int mode) async {
|
||||
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
final value = await _androidCacheChannel.invokeMethod<num>(
|
||||
'getCacheSize',
|
||||
<String, dynamic>{'mode': mode},
|
||||
);
|
||||
return value?.toInt() ?? 0;
|
||||
} catch (error) {
|
||||
debugPrint('[MEDIA-CACHE][18.7.15] Android size: $error');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAndroidMediaCache(int mode) async {
|
||||
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _androidCacheChannel.invokeMethod<bool>(
|
||||
'clearCache',
|
||||
<String, dynamic>{'mode': mode},
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('[MEDIA-CACHE][18.7.15] Android clear: $error');
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> currentCacheSizeBytes() async {
|
||||
return directorySizeBytes(await resolveCacheRoot());
|
||||
}
|
||||
|
||||
Future<int> defaultCacheSizeBytes() async {
|
||||
return directorySizeBytes(Directory(defaultCachePath));
|
||||
}
|
||||
|
||||
Future<int> directorySizeBytes(Directory directory) async {
|
||||
if (!await directory.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
|
||||
try {
|
||||
await for (final entity in directory.list(
|
||||
recursive: true,
|
||||
followLinks: false,
|
||||
)) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
total += await entity.length();
|
||||
} catch (_) {
|
||||
// Un fichier verrouillé ne doit pas bloquer l'affichage.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Un dossier partiellement inaccessible ne doit pas bloquer l'app.
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
Future<void> clearCurrentCache() async {
|
||||
final root = await resolveCacheRoot();
|
||||
await _deleteChildren(root);
|
||||
}
|
||||
|
||||
Future<void> clearDefaultCache() async {
|
||||
final root = Directory(defaultCachePath);
|
||||
if (!await root.exists()) {
|
||||
return;
|
||||
}
|
||||
await _deleteChildren(root);
|
||||
}
|
||||
|
||||
Future<void> _deleteChildren(Directory root) async {
|
||||
try {
|
||||
final entities = await root.list(followLinks: false).toList();
|
||||
for (final entity in entities) {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} catch (_) {
|
||||
// Un fichier encore utilisé sera conservé et pourra être supprimé
|
||||
// lors du prochain nettoyage.
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Le nettoyage ne doit jamais empêcher l'application de fonctionner.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> touchVideoDirectory(Directory videoDirectory) async {
|
||||
final marker = File(
|
||||
'${videoDirectory.path}${Platform.pathSeparator}.last_access',
|
||||
);
|
||||
|
||||
try {
|
||||
await marker.writeAsString(
|
||||
DateTime.now().toIso8601String(),
|
||||
flush: true,
|
||||
);
|
||||
} catch (_) {
|
||||
// Non bloquant.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enforceLimit({String? keepVideoId}) async {
|
||||
await ensureInitialized();
|
||||
|
||||
if (!Platform.isWindows) {
|
||||
return;
|
||||
}
|
||||
|
||||
final limitGb = maxCacheGb.value;
|
||||
if (limitGb <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final root = await resolveCacheRoot();
|
||||
final maxBytes = limitGb * 1024 * 1024 * 1024;
|
||||
|
||||
final entries = <_CacheEntry>[];
|
||||
var totalBytes = 0;
|
||||
|
||||
try {
|
||||
final children = await root.list(followLinks: false).toList();
|
||||
|
||||
for (final entity in children) {
|
||||
final name = entity.uri.pathSegments
|
||||
.where((part) => part.isNotEmpty)
|
||||
.last;
|
||||
|
||||
int bytes = 0;
|
||||
DateTime lastAccess = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
if (entity is Directory) {
|
||||
bytes = await directorySizeBytes(entity);
|
||||
lastAccess = await _lastAccess(entity);
|
||||
} else if (entity is File) {
|
||||
try {
|
||||
bytes = await entity.length();
|
||||
lastAccess = (await entity.stat()).modified;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
entries.add(
|
||||
_CacheEntry(
|
||||
entity: entity,
|
||||
name: name,
|
||||
bytes: bytes,
|
||||
lastAccess: lastAccess,
|
||||
),
|
||||
);
|
||||
totalBytes += bytes;
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalBytes <= maxBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On descend à 85 % de la limite pour éviter un nettoyage à chaque
|
||||
// petite écriture suivante.
|
||||
final targetBytes = (maxBytes * 0.85).round();
|
||||
|
||||
entries.sort(
|
||||
(a, b) => a.lastAccess.compareTo(b.lastAccess),
|
||||
);
|
||||
|
||||
for (final entry in entries) {
|
||||
if (totalBytes <= targetBytes) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (keepVideoId != null && entry.name == keepVideoId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await entry.entity.delete(recursive: true);
|
||||
totalBytes -= entry.bytes;
|
||||
|
||||
debugPrint(
|
||||
'[MEDIA-CACHE][18.7.15] LRU supprimé: '
|
||||
'${entry.name} (${_formatBytes(entry.bytes)})',
|
||||
);
|
||||
} catch (_) {
|
||||
// Probablement encore utilisé par le lecteur.
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[MEDIA-CACHE][18.7.15] taille après nettoyage: '
|
||||
'${_formatBytes(totalBytes)} / ${limitGb}Go',
|
||||
);
|
||||
}
|
||||
|
||||
Future<DateTime> _lastAccess(Directory directory) async {
|
||||
final marker = File(
|
||||
'${directory.path}${Platform.pathSeparator}.last_access',
|
||||
);
|
||||
|
||||
try {
|
||||
if (await marker.exists()) {
|
||||
return (await marker.stat()).modified;
|
||||
}
|
||||
return (await directory.stat()).modified;
|
||||
} catch (_) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
}
|
||||
|
||||
bool _samePath(String a, String b) {
|
||||
String normalize(String value) {
|
||||
var normalized = value
|
||||
.replaceAll('/', Platform.pathSeparator)
|
||||
.replaceAll('\\', Platform.pathSeparator)
|
||||
.trim();
|
||||
while (normalized.endsWith(Platform.pathSeparator)) {
|
||||
normalized = normalized.substring(0, normalized.length - 1);
|
||||
}
|
||||
return Platform.isWindows
|
||||
? normalized.toLowerCase()
|
||||
: normalized;
|
||||
}
|
||||
|
||||
return normalize(a) == normalize(b);
|
||||
}
|
||||
|
||||
static String formatBytesForUi(int bytes) => _formatBytes(bytes);
|
||||
|
||||
static String _formatBytes(int bytes) {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} Go';
|
||||
}
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} Mo';
|
||||
}
|
||||
return '${(bytes / 1024).toStringAsFixed(0)} Ko';
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
const _CacheEntry({
|
||||
required this.entity,
|
||||
required this.name,
|
||||
required this.bytes,
|
||||
required this.lastAccess,
|
||||
});
|
||||
|
||||
final FileSystemEntity entity;
|
||||
final String name;
|
||||
final int bytes;
|
||||
final DateTime lastAccess;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import '../models/video_item.dart';
|
||||
|
||||
class PreferredLanguageOption {
|
||||
const PreferredLanguageOption({
|
||||
required this.code,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class PreferredLanguageService {
|
||||
PreferredLanguageService._();
|
||||
|
||||
static const List<PreferredLanguageOption> options =
|
||||
<PreferredLanguageOption>[
|
||||
PreferredLanguageOption(code: 'auto', label: 'Auto'),
|
||||
PreferredLanguageOption(code: 'fr', label: 'Français (fr)'),
|
||||
PreferredLanguageOption(code: 'en', label: 'English (en)'),
|
||||
PreferredLanguageOption(code: 'ar', label: 'العربية (ar)'),
|
||||
PreferredLanguageOption(code: 'de', label: 'Deutsch (de)'),
|
||||
PreferredLanguageOption(code: 'es', label: 'Español (es)'),
|
||||
];
|
||||
|
||||
static bool isSupported(String code) {
|
||||
return options.any((option) => option.code == code);
|
||||
}
|
||||
|
||||
static String labelFor(String code) {
|
||||
for (final option in options) {
|
||||
if (option.code == code) {
|
||||
return option.label;
|
||||
}
|
||||
}
|
||||
return 'Auto';
|
||||
}
|
||||
|
||||
static String nativeSearchHint(String code) {
|
||||
return switch (code) {
|
||||
'fr' => 'français',
|
||||
'en' => 'English',
|
||||
'ar' => 'عربي',
|
||||
'de' => 'Deutsch',
|
||||
'es' => 'español',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
static String dubbingSearchHint(String code) {
|
||||
return switch (code) {
|
||||
'fr' => 'VF doublage français version française',
|
||||
'en' => 'English dub dubbed',
|
||||
'ar' => 'مدبلج عربي دبلجة عربية',
|
||||
'de' => 'deutsche Synchronisation Deutsch',
|
||||
'es' => 'doblaje español versión española',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns a positive local boost. This is deliberately a preference,
|
||||
/// never a hard filter: videos in other languages remain eligible.
|
||||
static int scoreVideo(VideoItem video, String preferredLanguage) {
|
||||
if (preferredLanguage == 'auto') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final raw = '${video.title} ${video.description} ${video.author}';
|
||||
final text = _normalize(raw);
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (_hasDubbingMarker(text, preferredLanguage)) {
|
||||
score += 18;
|
||||
score += _popularityBonus(video.viewCount);
|
||||
}
|
||||
|
||||
if (_looksLikeLanguage(text, preferredLanguage)) {
|
||||
score += 12;
|
||||
}
|
||||
|
||||
return score.clamp(0, 32).toInt();
|
||||
}
|
||||
|
||||
static bool isLikelyDubbed(VideoItem video, String preferredLanguage) {
|
||||
if (preferredLanguage == 'auto') {
|
||||
return false;
|
||||
}
|
||||
|
||||
final text = _normalize(
|
||||
'${video.title} ${video.description} ${video.author}',
|
||||
);
|
||||
|
||||
return _hasDubbingMarker(text, preferredLanguage);
|
||||
}
|
||||
|
||||
static List<VideoItem> rankVideos(
|
||||
List<VideoItem> videos,
|
||||
String preferredLanguage,
|
||||
) {
|
||||
if (preferredLanguage == 'auto' || videos.length < 2) {
|
||||
return videos;
|
||||
}
|
||||
|
||||
final indexed = videos.asMap().entries.toList(growable: false);
|
||||
|
||||
final sorted = [...indexed]
|
||||
..sort((a, b) {
|
||||
final aScore = scoreVideo(a.value, preferredLanguage);
|
||||
final bScore = scoreVideo(b.value, preferredLanguage);
|
||||
|
||||
final scoreCompare = bScore.compareTo(aScore);
|
||||
if (scoreCompare != 0) {
|
||||
return scoreCompare;
|
||||
}
|
||||
|
||||
return a.key.compareTo(b.key);
|
||||
});
|
||||
|
||||
return sorted.map((entry) => entry.value).toList(growable: false);
|
||||
}
|
||||
|
||||
|
||||
static int _popularityBonus(int? viewCount) {
|
||||
if (viewCount == null || viewCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (viewCount >= 10000000) {
|
||||
return 8;
|
||||
}
|
||||
if (viewCount >= 1000000) {
|
||||
return 6;
|
||||
}
|
||||
if (viewCount >= 100000) {
|
||||
return 4;
|
||||
}
|
||||
if (viewCount >= 10000) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static bool _hasDubbingMarker(String text, String code) {
|
||||
final markers = switch (code) {
|
||||
'fr' => const <String>[
|
||||
' vf ',
|
||||
'[vf]',
|
||||
'(vf)',
|
||||
'version francaise',
|
||||
'version française',
|
||||
'double en francais',
|
||||
'doublé en français',
|
||||
'doublage francais',
|
||||
'doublage français',
|
||||
'french dub',
|
||||
'french dubbed',
|
||||
'audio francais',
|
||||
'audio français',
|
||||
],
|
||||
'en' => const <String>[
|
||||
'english dub',
|
||||
'english dubbed',
|
||||
'dubbed in english',
|
||||
'english audio',
|
||||
],
|
||||
'ar' => const <String>[
|
||||
'مدبلج عربي',
|
||||
'مدبلجة عربية',
|
||||
'دبلجة عربية',
|
||||
'باللغة العربية',
|
||||
],
|
||||
'de' => const <String>[
|
||||
'deutsche synchronisation',
|
||||
'deutsch synchronisiert',
|
||||
'deutsche synchro',
|
||||
'german dub',
|
||||
],
|
||||
'es' => const <String>[
|
||||
'doblaje espanol',
|
||||
'doblaje español',
|
||||
'version espanola',
|
||||
'versión española',
|
||||
'spanish dub',
|
||||
'audio espanol',
|
||||
'audio español',
|
||||
],
|
||||
_ => const <String>[],
|
||||
};
|
||||
|
||||
return markers.any(text.contains);
|
||||
}
|
||||
|
||||
static bool _looksLikeLanguage(String text, String code) {
|
||||
if (code == 'ar') {
|
||||
final arabic = RegExp(r'[\u0600-\u06FF]');
|
||||
final matches = arabic.allMatches(text).length;
|
||||
return matches >= 5;
|
||||
}
|
||||
|
||||
final words = text
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((word) => word.length >= 2)
|
||||
.toList(growable: false);
|
||||
|
||||
final markers = switch (code) {
|
||||
'fr' => const <String>{
|
||||
'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'dans', 'avec',
|
||||
'pour', 'sur', 'est', 'sont', 'comment', 'pourquoi', 'nouveau',
|
||||
'nouvelle', 'francais', 'français', 'voici', 'tout', 'tous',
|
||||
},
|
||||
'en' => const <String>{
|
||||
'the', 'and', 'with', 'for', 'from', 'this', 'that', 'how', 'why',
|
||||
'what', 'new', 'best', 'review', 'explained', 'english',
|
||||
},
|
||||
'de' => const <String>{
|
||||
'der', 'die', 'das', 'und', 'mit', 'für', 'fur', 'von', 'ist', 'wie',
|
||||
'warum', 'neu', 'neue', 'deutsch', 'erklart', 'erklärt',
|
||||
},
|
||||
'es' => const <String>{
|
||||
'el', 'la', 'los', 'las', 'un', 'una', 'con', 'para', 'por', 'como',
|
||||
'cómo', 'nuevo', 'nueva', 'espanol', 'español', 'explicado',
|
||||
},
|
||||
_ => const <String>{},
|
||||
};
|
||||
|
||||
var hits = 0;
|
||||
for (final word in words) {
|
||||
if (markers.contains(word)) {
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
|
||||
return hits >= 2;
|
||||
}
|
||||
|
||||
static String _normalize(String value) {
|
||||
return ' ${value.toLowerCase()}'
|
||||
.replaceAll(RegExp(r'[^a-z0-9à-ÿ\u0600-\u06ff\[\]\(\)]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trimRight() +
|
||||
' ';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class SponsorSegment {
|
||||
const SponsorSegment({
|
||||
required this.id,
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final Duration start;
|
||||
final Duration end;
|
||||
|
||||
Duration get duration => end - start;
|
||||
|
||||
bool contains(Duration position) {
|
||||
return position >= start && position < end;
|
||||
}
|
||||
}
|
||||
|
||||
class SponsorBlockService {
|
||||
SponsorBlockService._();
|
||||
|
||||
static final SponsorBlockService instance =
|
||||
SponsorBlockService._();
|
||||
|
||||
final Map<String, Future<List<SponsorSegment>>> _cache =
|
||||
<String, Future<List<SponsorSegment>>>{};
|
||||
|
||||
Future<List<SponsorSegment>> getSponsorSegments(
|
||||
String videoId,
|
||||
) {
|
||||
return _cache.putIfAbsent(
|
||||
videoId,
|
||||
() => _fetch(videoId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SponsorSegment>> _fetch(String videoId) async {
|
||||
try {
|
||||
// SponsorBlock's k-anonymity endpoint receives only the first four
|
||||
// hexadecimal characters of SHA-256(videoId), not the exact ID.
|
||||
final fullHash = sha256
|
||||
.convert(utf8.encode(videoId))
|
||||
.toString();
|
||||
final prefix = fullHash.substring(0, 4);
|
||||
|
||||
final uri = Uri.https(
|
||||
'sponsor.ajay.app',
|
||||
'/api/skipSegments/$prefix',
|
||||
<String, String>{
|
||||
'service': 'YouTube',
|
||||
'categories': jsonEncode(<String>['sponsor']),
|
||||
},
|
||||
);
|
||||
|
||||
final response = await http
|
||||
.get(
|
||||
uri,
|
||||
headers: const <String, String>{
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'YouTubePersonal/1.0',
|
||||
},
|
||||
)
|
||||
.timeout(const Duration(seconds: 6));
|
||||
|
||||
if (response.statusCode == 404) {
|
||||
return const <SponsorSegment>[];
|
||||
}
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return const <SponsorSegment>[];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! List) {
|
||||
return const <SponsorSegment>[];
|
||||
}
|
||||
|
||||
Map? matchingVideo;
|
||||
|
||||
for (final item in decoded) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final responseVideoId = item['videoID'];
|
||||
final responseHash = item['hash'];
|
||||
|
||||
if (responseVideoId == videoId || responseHash == fullHash) {
|
||||
matchingVideo = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingVideo == null) {
|
||||
return const <SponsorSegment>[];
|
||||
}
|
||||
|
||||
final rawSegments = matchingVideo['segments'];
|
||||
if (rawSegments is! List) {
|
||||
return const <SponsorSegment>[];
|
||||
}
|
||||
|
||||
final result = <SponsorSegment>[];
|
||||
|
||||
for (final item in rawSegments) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item['category'] != 'sponsor') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final actionType = item['actionType'];
|
||||
if (actionType is String && actionType != 'skip') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final segment = item['segment'];
|
||||
if (segment is! List || segment.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final start = segment[0];
|
||||
final end = segment[1];
|
||||
|
||||
if (start is! num || end is! num || end <= start) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = item['UUID'] is String
|
||||
? item['UUID'] as String
|
||||
: '${start.toDouble()}-${end.toDouble()}';
|
||||
|
||||
result.add(
|
||||
SponsorSegment(
|
||||
id: id,
|
||||
start: Duration(
|
||||
milliseconds: (start.toDouble() * 1000).round(),
|
||||
),
|
||||
end: Duration(
|
||||
milliseconds: (end.toDouble() * 1000).round(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.start.compareTo(b.start));
|
||||
return List.unmodifiable(result);
|
||||
} catch (_) {
|
||||
// Sponsor skipping must never block or break normal playback.
|
||||
return const <SponsorSegment>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/channel_details.dart';
|
||||
|
||||
/// Robust public channel metadata fallback for Windows.
|
||||
///
|
||||
/// youtube_explode_dart currently returns enough metadata for some channels,
|
||||
/// but on current YouTube layouts subscriber/about fields may be null. The
|
||||
/// project already ships a current yt-dlp nightly for HD playback, so Windows
|
||||
/// can reuse it without adding another dependency or a Google login.
|
||||
class WindowsChannelMetadataService {
|
||||
WindowsChannelMetadataService._();
|
||||
|
||||
static final WindowsChannelMetadataService instance =
|
||||
WindowsChannelMetadataService._();
|
||||
|
||||
final Map<String, Future<ChannelDetails?>> _cache =
|
||||
<String, Future<ChannelDetails?>>{};
|
||||
|
||||
Future<ChannelDetails?> getDetails(
|
||||
String channelId,
|
||||
) {
|
||||
if (!Platform.isWindows || channelId.trim().isEmpty) {
|
||||
return Future<ChannelDetails?>.value(null);
|
||||
}
|
||||
|
||||
return _cache.putIfAbsent(
|
||||
channelId,
|
||||
() => _load(channelId),
|
||||
);
|
||||
}
|
||||
|
||||
void invalidate(String channelId) {
|
||||
_cache.remove(channelId);
|
||||
}
|
||||
|
||||
Future<ChannelDetails?> _load(
|
||||
String channelId,
|
||||
) async {
|
||||
final ytDlp = _resolveYtDlp();
|
||||
|
||||
if (!await ytDlp.exists()) {
|
||||
debugPrint(
|
||||
'[CHANNEL-YTDLP][18.5.2] yt-dlp introuvable: ${ytDlp.path}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? videosData;
|
||||
Map<String, dynamic>? aboutData;
|
||||
|
||||
try {
|
||||
videosData = await _extract(
|
||||
ytDlp,
|
||||
'https://www.youtube.com/channel/$channelId/videos',
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[CHANNEL-YTDLP][18.5.2] /videos échoué: $error',
|
||||
);
|
||||
}
|
||||
|
||||
// Current yt-dlp channel extraction normally provides followers on the
|
||||
// channel URL. The About route can additionally expose channel views and
|
||||
// description on layouts where /videos omits them. It is intentionally
|
||||
// best-effort because YouTube has changed the About page several times.
|
||||
if (_intValue(videosData?['view_count']) == null ||
|
||||
_stringValue(videosData?['description']) == null) {
|
||||
try {
|
||||
aboutData = await _extract(
|
||||
ytDlp,
|
||||
'https://www.youtube.com/channel/$channelId/about',
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[CHANNEL-YTDLP][18.5.2] /about ignoré: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final data = _mergeMaps(
|
||||
primary: videosData,
|
||||
secondary: aboutData,
|
||||
);
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final subscribers =
|
||||
_intValue(data['channel_follower_count']);
|
||||
final views = _intValue(data['view_count']);
|
||||
final rawVideoCount =
|
||||
_intValue(data['playlist_count']);
|
||||
final videoCount =
|
||||
rawVideoCount != null && rawVideoCount > 0
|
||||
? rawVideoCount
|
||||
: null;
|
||||
|
||||
final title =
|
||||
_stringValue(data['channel']) ??
|
||||
_stringValue(data['uploader']) ??
|
||||
_stringValue(data['title']) ??
|
||||
'';
|
||||
|
||||
final description =
|
||||
_stringValue(data['description']) ?? '';
|
||||
|
||||
final thumbnail = _bestThumbnail(
|
||||
data['thumbnails'],
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'[CHANNEL-YTDLP][18.5.2] channel=$channelId '
|
||||
'subs=${subscribers ?? 'NA'} '
|
||||
'views=${views ?? 'NA'} '
|
||||
'videos=${videoCount ?? 'NA'} '
|
||||
'title=${title.isEmpty ? 'NA' : title}',
|
||||
);
|
||||
|
||||
if (subscribers == null &&
|
||||
views == null &&
|
||||
videoCount == null &&
|
||||
title.isEmpty &&
|
||||
description.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ChannelDetails(
|
||||
channelId: channelId,
|
||||
title: title,
|
||||
description: description,
|
||||
thumbnailUrl: thumbnail ?? '',
|
||||
viewCount: views,
|
||||
subscriberCount: subscribers,
|
||||
hiddenSubscriberCount: subscribers == null,
|
||||
videoCount: videoCount,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _extract(
|
||||
File ytDlp,
|
||||
String url,
|
||||
) async {
|
||||
final args = <String>[
|
||||
'--ignore-config',
|
||||
'--quiet',
|
||||
'--no-warnings',
|
||||
'--encoding',
|
||||
'utf-8',
|
||||
'--socket-timeout',
|
||||
'15',
|
||||
'--retries',
|
||||
'2',
|
||||
'--flat-playlist',
|
||||
'--playlist-items',
|
||||
'0',
|
||||
'--skip-download',
|
||||
'--dump-single-json',
|
||||
url,
|
||||
];
|
||||
|
||||
final result = await Process.run(
|
||||
ytDlp.path,
|
||||
args,
|
||||
workingDirectory: ytDlp.parent.path,
|
||||
runInShell: false,
|
||||
stdoutEncoding: utf8,
|
||||
stderrEncoding: utf8,
|
||||
).timeout(
|
||||
const Duration(seconds: 25),
|
||||
);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
throw StateError(
|
||||
_compactError(result.stderr?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
final stdout = result.stdout?.toString().trim() ?? '';
|
||||
|
||||
if (stdout.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normally --dump-single-json produces one JSON document. If yt-dlp ever
|
||||
// prints a harmless prefix, recover from the first opening brace.
|
||||
final jsonStart = stdout.indexOf('{');
|
||||
final jsonText = jsonStart >= 0
|
||||
? stdout.substring(jsonStart)
|
||||
: stdout;
|
||||
|
||||
final decoded = jsonDecode(jsonText);
|
||||
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _mergeMaps({
|
||||
required Map<String, dynamic>? primary,
|
||||
required Map<String, dynamic>? secondary,
|
||||
}) {
|
||||
if (primary == null && secondary == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final merged = <String, dynamic>{};
|
||||
|
||||
if (secondary != null) {
|
||||
merged.addAll(secondary);
|
||||
}
|
||||
|
||||
if (primary != null) {
|
||||
// Prefer non-null values from /videos, but don't overwrite useful
|
||||
// About metadata with nulls.
|
||||
for (final entry in primary.entries) {
|
||||
if (entry.value != null) {
|
||||
merged[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
File _resolveYtDlp() {
|
||||
final executableDirectory =
|
||||
File(Platform.resolvedExecutable).parent;
|
||||
|
||||
final bundled = File(
|
||||
'${executableDirectory.path}'
|
||||
'${Platform.pathSeparator}data'
|
||||
'${Platform.pathSeparator}flutter_assets'
|
||||
'${Platform.pathSeparator}assets'
|
||||
'${Platform.pathSeparator}windows_hd'
|
||||
'${Platform.pathSeparator}yt-dlp.exe',
|
||||
);
|
||||
|
||||
if (bundled.existsSync()) {
|
||||
return bundled;
|
||||
}
|
||||
|
||||
return File(
|
||||
'${Directory.current.path}'
|
||||
'${Platform.pathSeparator}assets'
|
||||
'${Platform.pathSeparator}windows_hd'
|
||||
'${Platform.pathSeparator}yt-dlp.exe',
|
||||
);
|
||||
}
|
||||
|
||||
int? _intValue(dynamic value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
if (value is String) {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _stringValue(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final text = value.toString().trim();
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
String? _bestThumbnail(dynamic raw) {
|
||||
if (raw is! List || raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String? bestUrl;
|
||||
int bestArea = -1;
|
||||
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final url = _stringValue(item['url']);
|
||||
if (url == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final width = _intValue(item['width']) ?? 0;
|
||||
final height = _intValue(item['height']) ?? 0;
|
||||
final area = width * height;
|
||||
|
||||
if (bestUrl == null || area > bestArea) {
|
||||
bestUrl = url;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
|
||||
return bestUrl;
|
||||
}
|
||||
|
||||
String _compactError(String value) {
|
||||
final normalized = value
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
if (normalized.length <= 350) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return '${normalized.substring(0, 350)}…';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
|
||||
/// Bulk channel-video metadata for Windows.
|
||||
///
|
||||
/// One yt-dlp process reads the flat channel playlist instead of issuing one
|
||||
/// YouTube watch-page request for every video. This prevents the request storm
|
||||
/// that caused RequestLimitExceededException in step 18.7.
|
||||
class WindowsChannelVideoCatalogService {
|
||||
WindowsChannelVideoCatalogService._();
|
||||
|
||||
static final WindowsChannelVideoCatalogService instance =
|
||||
WindowsChannelVideoCatalogService._();
|
||||
|
||||
final Map<String, Future<Map<String, VideoItem>>> _cache =
|
||||
<String, Future<Map<String, VideoItem>>>{};
|
||||
|
||||
Future<Map<String, VideoItem>> getMetadata(
|
||||
String channelId, {
|
||||
bool force = false,
|
||||
}) {
|
||||
if (!Platform.isWindows ||
|
||||
channelId.trim().isEmpty) {
|
||||
return Future<Map<String, VideoItem>>.value(
|
||||
const <String, VideoItem>{},
|
||||
);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
_cache.remove(channelId);
|
||||
}
|
||||
|
||||
return _cache.putIfAbsent(
|
||||
channelId,
|
||||
() => _load(channelId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, VideoItem>> _load(
|
||||
String channelId,
|
||||
) async {
|
||||
final ytDlp = _resolveYtDlp();
|
||||
|
||||
if (!await ytDlp.exists()) {
|
||||
debugPrint(
|
||||
'[CHANNEL-CATALOG][18.7.6] '
|
||||
'yt-dlp introuvable: ${ytDlp.path}',
|
||||
);
|
||||
|
||||
return const <String, VideoItem>{};
|
||||
}
|
||||
|
||||
final args = <String>[
|
||||
'--ignore-config',
|
||||
'--quiet',
|
||||
'--no-warnings',
|
||||
'--encoding',
|
||||
'utf-8',
|
||||
'--socket-timeout',
|
||||
'15',
|
||||
'--retries',
|
||||
'1',
|
||||
'--flat-playlist',
|
||||
'--extractor-args',
|
||||
'youtubetab:approximate_date',
|
||||
'--dump-json',
|
||||
'https://www.youtube.com/channel/$channelId/videos',
|
||||
];
|
||||
|
||||
debugPrint(
|
||||
'[CHANNEL-CATALOG][18.7.6] '
|
||||
'bulk start channel=$channelId',
|
||||
);
|
||||
|
||||
Process? process;
|
||||
|
||||
try {
|
||||
process = await Process.start(
|
||||
ytDlp.path,
|
||||
args,
|
||||
workingDirectory:
|
||||
ytDlp.parent.path,
|
||||
runInShell: false,
|
||||
mode:
|
||||
ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
final result =
|
||||
<String, VideoItem>{};
|
||||
|
||||
final stderr = StringBuffer();
|
||||
|
||||
final stderrSubscription =
|
||||
process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen(
|
||||
(text) {
|
||||
if (stderr.length < 3000) {
|
||||
stderr.write(text);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final completer =
|
||||
Completer<void>();
|
||||
|
||||
final stdoutSubscription =
|
||||
process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.transform(
|
||||
const LineSplitter(),
|
||||
)
|
||||
.listen(
|
||||
(line) {
|
||||
final text = line.trim();
|
||||
|
||||
if (text.isEmpty ||
|
||||
!text.startsWith('{')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded =
|
||||
jsonDecode(text);
|
||||
|
||||
if (decoded is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
final data =
|
||||
Map<String, dynamic>.from(
|
||||
decoded,
|
||||
);
|
||||
|
||||
final video =
|
||||
_fromFlatJson(
|
||||
data,
|
||||
);
|
||||
|
||||
if (video != null) {
|
||||
result[video.id] = video;
|
||||
}
|
||||
} catch (_) {
|
||||
// One malformed flat entry must not abort the catalogue.
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
final exitCodeFuture =
|
||||
process.exitCode;
|
||||
|
||||
await Future.wait<void>([
|
||||
completer.future,
|
||||
exitCodeFuture.then<void>(
|
||||
(_) {},
|
||||
),
|
||||
]).timeout(
|
||||
const Duration(seconds: 35),
|
||||
onTimeout: () {
|
||||
process?.kill();
|
||||
|
||||
throw TimeoutException(
|
||||
'Catalogue yt-dlp > 35 s',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await stdoutSubscription.cancel();
|
||||
await stderrSubscription.cancel();
|
||||
|
||||
final exitCode =
|
||||
await exitCodeFuture;
|
||||
|
||||
if (exitCode != 0 &&
|
||||
result.isEmpty) {
|
||||
final errorText = stderr
|
||||
.toString()
|
||||
.replaceAll(
|
||||
RegExp(r'\s+'),
|
||||
' ',
|
||||
)
|
||||
.trim();
|
||||
|
||||
debugPrint(
|
||||
'[CHANNEL-CATALOG][18.7.6] '
|
||||
'bulk code=$exitCode '
|
||||
'${errorText.isEmpty ? '' : errorText}',
|
||||
);
|
||||
|
||||
return const <String, VideoItem>{};
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[CHANNEL-CATALOG][18.7.6] '
|
||||
'bulk ready channel=$channelId '
|
||||
'videos=${result.length}',
|
||||
);
|
||||
|
||||
return Map.unmodifiable(result);
|
||||
} catch (error) {
|
||||
process?.kill();
|
||||
|
||||
debugPrint(
|
||||
'[CHANNEL-CATALOG][18.7.6] '
|
||||
'bulk fallback: $error',
|
||||
);
|
||||
|
||||
return const <String, VideoItem>{};
|
||||
}
|
||||
}
|
||||
|
||||
VideoItem? _fromFlatJson(
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
final id =
|
||||
_string(data['id']);
|
||||
|
||||
if (id == null ||
|
||||
id.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final title =
|
||||
_string(data['title']) ??
|
||||
'Vidéo YouTube';
|
||||
|
||||
final channelId =
|
||||
_string(data['channel_id']) ??
|
||||
_string(data['uploader_id']);
|
||||
|
||||
final author =
|
||||
_string(data['channel']) ??
|
||||
_string(data['uploader']) ??
|
||||
'';
|
||||
|
||||
final durationSeconds =
|
||||
_number(data['duration']);
|
||||
|
||||
final timestamp =
|
||||
_number(data['timestamp']) ??
|
||||
_number(
|
||||
data['release_timestamp'],
|
||||
);
|
||||
|
||||
final uploadDate =
|
||||
timestamp != null
|
||||
? DateTime
|
||||
.fromMillisecondsSinceEpoch(
|
||||
(timestamp * 1000)
|
||||
.round(),
|
||||
isUtc: true,
|
||||
)
|
||||
.toLocal()
|
||||
: _parseUploadDate(
|
||||
_string(
|
||||
data['upload_date'],
|
||||
),
|
||||
);
|
||||
|
||||
final thumbnail =
|
||||
_bestThumbnail(
|
||||
data['thumbnails'],
|
||||
) ??
|
||||
'https://i.ytimg.com/vi/$id/hqdefault.jpg';
|
||||
|
||||
return VideoItem(
|
||||
id: id,
|
||||
title: title,
|
||||
author: author,
|
||||
channelId: channelId,
|
||||
thumbnailUrl: thumbnail,
|
||||
duration: durationSeconds != null &&
|
||||
durationSeconds > 0
|
||||
? Duration(
|
||||
milliseconds:
|
||||
(durationSeconds * 1000)
|
||||
.round(),
|
||||
)
|
||||
: null,
|
||||
viewCount:
|
||||
_integer(data['view_count']),
|
||||
likeCount:
|
||||
_integer(data['like_count']),
|
||||
commentCount:
|
||||
_integer(data['comment_count']),
|
||||
description:
|
||||
_string(data['description']) ??
|
||||
'',
|
||||
uploadDate: uploadDate,
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? _parseUploadDate(
|
||||
String? raw,
|
||||
) {
|
||||
if (raw == null ||
|
||||
raw.length != 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final year =
|
||||
int.tryParse(
|
||||
raw.substring(0, 4),
|
||||
);
|
||||
final month =
|
||||
int.tryParse(
|
||||
raw.substring(4, 6),
|
||||
);
|
||||
final day =
|
||||
int.tryParse(
|
||||
raw.substring(6, 8),
|
||||
);
|
||||
|
||||
if (year == null ||
|
||||
month == null ||
|
||||
day == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _bestThumbnail(
|
||||
dynamic raw,
|
||||
) {
|
||||
if (raw is! List ||
|
||||
raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String? best;
|
||||
int bestArea = -1;
|
||||
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final url =
|
||||
_string(item['url']);
|
||||
|
||||
if (url == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final width =
|
||||
_integer(item['width']) ??
|
||||
0;
|
||||
final height =
|
||||
_integer(item['height']) ??
|
||||
0;
|
||||
|
||||
final area =
|
||||
width * height;
|
||||
|
||||
if (best == null ||
|
||||
area > bestArea) {
|
||||
best = url;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
int? _integer(dynamic value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
if (value is String) {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
double? _number(dynamic value) {
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
|
||||
if (value is String) {
|
||||
return double.tryParse(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _string(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final text =
|
||||
value.toString().trim();
|
||||
|
||||
return text.isEmpty
|
||||
? null
|
||||
: text;
|
||||
}
|
||||
|
||||
File _resolveYtDlp() {
|
||||
final executableDirectory =
|
||||
File(
|
||||
Platform.resolvedExecutable,
|
||||
).parent;
|
||||
|
||||
final bundled = File(
|
||||
'${executableDirectory.path}'
|
||||
'${Platform.pathSeparator}data'
|
||||
'${Platform.pathSeparator}flutter_assets'
|
||||
'${Platform.pathSeparator}assets'
|
||||
'${Platform.pathSeparator}windows_hd'
|
||||
'${Platform.pathSeparator}yt-dlp.exe',
|
||||
);
|
||||
|
||||
if (bundled.existsSync()) {
|
||||
return bundled;
|
||||
}
|
||||
|
||||
return File(
|
||||
'${Directory.current.path}'
|
||||
'${Platform.pathSeparator}assets'
|
||||
'${Platform.pathSeparator}windows_hd'
|
||||
'${Platform.pathSeparator}yt-dlp.exe',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class WindowsEmbeddedCacheSession {
|
||||
const WindowsEmbeddedCacheSession({
|
||||
required this.videoUrl,
|
||||
required this.audioUrl,
|
||||
});
|
||||
|
||||
final String videoUrl;
|
||||
final String audioUrl;
|
||||
}
|
||||
|
||||
/// Cache progressif Windows pour le chemin `web_embedded`.
|
||||
///
|
||||
/// Le test manuel a démontré que yt-dlp télécharge intégralement :
|
||||
/// - le format vidéo 299 (1080p60) ;
|
||||
/// - le format audio 251 (Opus).
|
||||
///
|
||||
/// Au lieu de donner les URLs googlevideo à libmpv, cette classe lance les
|
||||
/// mêmes commandes yt-dlp validées vers deux fichiers temporaires qui
|
||||
/// grandissent pendant le téléchargement.
|
||||
///
|
||||
/// libmpv lit uniquement un serveur HTTP loopback :
|
||||
///
|
||||
/// libmpv -> 127.0.0.1 -> fichier temporaire en croissance
|
||||
/// ^
|
||||
/// |
|
||||
/// yt-dlp web_embedded
|
||||
/// |
|
||||
/// googlevideo
|
||||
///
|
||||
/// Le lecteur peut commencer avant la fin du téléchargement complet.
|
||||
/// Lors d'un seek vers une zone qui n'est pas encore en cache, le serveur
|
||||
/// attend simplement que yt-dlp atteigne cette zone.
|
||||
class WindowsEmbeddedCacheRelayService {
|
||||
HttpServer? _server;
|
||||
StreamSubscription<HttpRequest>? _serverSubscription;
|
||||
Directory? _sessionDirectory;
|
||||
|
||||
final Map<String, _GrowingSource> _sources =
|
||||
<String, _GrowingSource>{};
|
||||
|
||||
Future<WindowsEmbeddedCacheSession> start({
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
required String videoFormatId,
|
||||
required String audioFormatId,
|
||||
required int videoLength,
|
||||
required int audioLength,
|
||||
required String videoExt,
|
||||
required String audioExt,
|
||||
required String videoMimeType,
|
||||
required String audioMimeType,
|
||||
}) async {
|
||||
if (!Platform.isWindows) {
|
||||
throw UnsupportedError(
|
||||
'Le cache web_embedded est réservé à Windows.',
|
||||
);
|
||||
}
|
||||
|
||||
await stop();
|
||||
|
||||
final ytDlp = File(ytDlpPath);
|
||||
final deno = File(denoPath);
|
||||
|
||||
if (!await ytDlp.exists()) {
|
||||
throw StateError(
|
||||
'yt-dlp.exe introuvable : $ytDlpPath',
|
||||
);
|
||||
}
|
||||
|
||||
if (!await deno.exists()) {
|
||||
throw StateError(
|
||||
'deno.exe introuvable : $denoPath',
|
||||
);
|
||||
}
|
||||
|
||||
if (videoLength <= 0 || audioLength <= 0) {
|
||||
throw StateError(
|
||||
'Tailles web_embedded invalides : '
|
||||
'video=$videoLength audio=$audioLength',
|
||||
);
|
||||
}
|
||||
|
||||
final sessionDirectory =
|
||||
await Directory.systemTemp.createTemp(
|
||||
'youtube_personal_embedded_cache_',
|
||||
);
|
||||
|
||||
final videoFile = File(
|
||||
'${sessionDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'video.$videoExt',
|
||||
);
|
||||
|
||||
final audioFile = File(
|
||||
'${sessionDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'audio.$audioExt',
|
||||
);
|
||||
|
||||
final video = _GrowingSource(
|
||||
id: 'video',
|
||||
file: videoFile,
|
||||
formatId: videoFormatId,
|
||||
length: videoLength,
|
||||
contentType: videoMimeType,
|
||||
);
|
||||
|
||||
final audio = _GrowingSource(
|
||||
id: 'audio',
|
||||
file: audioFile,
|
||||
formatId: audioFormatId,
|
||||
length: audioLength,
|
||||
contentType: audioMimeType,
|
||||
);
|
||||
|
||||
_sessionDirectory = sessionDirectory;
|
||||
|
||||
_sources
|
||||
..clear()
|
||||
..['/video'] = video
|
||||
..['/audio'] = audio;
|
||||
|
||||
final server = await HttpServer.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
shared: false,
|
||||
);
|
||||
|
||||
_server = server;
|
||||
|
||||
_serverSubscription = server.listen(
|
||||
(request) {
|
||||
unawaited(_handleRequest(request));
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] erreur serveur local: $error',
|
||||
);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
// On lance vidéo et audio en parallèle. C'est volontaire :
|
||||
// yt-dlp peut imposer quelques secondes de sleep avant le téléchargement.
|
||||
// L'audio 251 est petit et sera normalement entièrement mis en cache
|
||||
// très rapidement, pendant que la vidéo continue à grossir.
|
||||
await Future.wait<void>([
|
||||
_startYtDlp(
|
||||
source: video,
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
),
|
||||
_startYtDlp(
|
||||
source: audio,
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
),
|
||||
]);
|
||||
|
||||
final base =
|
||||
'http://127.0.0.1:${server.port}';
|
||||
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] actif sur 127.0.0.1:${server.port} '
|
||||
'video=$videoFormatId audio=$audioFormatId',
|
||||
);
|
||||
|
||||
return WindowsEmbeddedCacheSession(
|
||||
videoUrl: '$base/video',
|
||||
audioUrl: '$base/audio',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final subscription = _serverSubscription;
|
||||
_serverSubscription = null;
|
||||
|
||||
if (subscription != null) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
final server = _server;
|
||||
_server = null;
|
||||
|
||||
if (server != null) {
|
||||
await server.close(force: true);
|
||||
}
|
||||
|
||||
final currentSources =
|
||||
_sources.values.toList(growable: false);
|
||||
|
||||
for (final source in currentSources) {
|
||||
final process = source.process;
|
||||
|
||||
if (process != null) {
|
||||
process.kill();
|
||||
}
|
||||
}
|
||||
|
||||
// Laisse Windows libérer les handles de fichiers/processus.
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 120),
|
||||
);
|
||||
|
||||
_sources.clear();
|
||||
|
||||
final directory = _sessionDirectory;
|
||||
_sessionDirectory = null;
|
||||
|
||||
if (directory != null) {
|
||||
try {
|
||||
if (await directory.exists()) {
|
||||
await directory.delete(
|
||||
recursive: true,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] nettoyage différé: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startYtDlp({
|
||||
required _GrowingSource source,
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
}) async {
|
||||
final args = <String>[
|
||||
'--ignore-config',
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--no-progress',
|
||||
'--socket-timeout',
|
||||
'20',
|
||||
'--retries',
|
||||
'5',
|
||||
'--fragment-retries',
|
||||
'5',
|
||||
'--js-runtimes',
|
||||
'deno:$denoPath',
|
||||
'--extractor-args',
|
||||
'youtube:player_client=web_embedded',
|
||||
'--force-overwrites',
|
||||
'--no-part',
|
||||
'-f',
|
||||
source.formatId,
|
||||
'-o',
|
||||
source.file.path,
|
||||
watchUrl,
|
||||
];
|
||||
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] lancement yt-dlp '
|
||||
'${source.id} format=${source.formatId}',
|
||||
);
|
||||
|
||||
final process = await Process.start(
|
||||
ytDlpPath,
|
||||
args,
|
||||
workingDirectory:
|
||||
File(ytDlpPath).parent.path,
|
||||
runInShell: false,
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
source.process = process;
|
||||
|
||||
process.stdout
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(text) {
|
||||
final compact =
|
||||
text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
if (compact.isNotEmpty &&
|
||||
compact.toLowerCase().contains('error')) {
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] ${source.id} stdout: $compact',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
process.stderr
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(text) {
|
||||
if (source.stderr.length < 6000) {
|
||||
final remaining =
|
||||
6000 - source.stderr.length;
|
||||
|
||||
source.stderr.write(
|
||||
text.length <= remaining
|
||||
? text
|
||||
: text.substring(0, remaining),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(
|
||||
process.exitCode.then(
|
||||
(code) async {
|
||||
source.exitCode = code;
|
||||
|
||||
if (!source.done.isCompleted) {
|
||||
source.done.complete(code);
|
||||
}
|
||||
|
||||
final size = await _safeLength(
|
||||
source.file,
|
||||
);
|
||||
|
||||
if (code == 0) {
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] ${source.id} téléchargé: '
|
||||
'$size/${source.length} octets',
|
||||
);
|
||||
} else {
|
||||
final stderr = source.stderr
|
||||
.toString()
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] ${source.id} yt-dlp code=$code'
|
||||
'${stderr.isEmpty ? '' : ': $stderr'}',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRequest(
|
||||
HttpRequest request,
|
||||
) async {
|
||||
final source = _sources[request.uri.path];
|
||||
|
||||
if (source == null) {
|
||||
request.response.statusCode =
|
||||
HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method != 'GET' &&
|
||||
request.method != 'HEAD') {
|
||||
request.response.statusCode =
|
||||
HttpStatus.methodNotAllowed;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final rangeHeader =
|
||||
request.headers.value(
|
||||
HttpHeaders.rangeHeader,
|
||||
);
|
||||
|
||||
final parsed = _parseRange(
|
||||
rangeHeader,
|
||||
source.length,
|
||||
);
|
||||
|
||||
if (rangeHeader != null && parsed == null) {
|
||||
request.response.statusCode =
|
||||
HttpStatus.requestedRangeNotSatisfiable;
|
||||
|
||||
request.response.headers.set(
|
||||
'content-range',
|
||||
'bytes */${source.length}',
|
||||
);
|
||||
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final start = parsed?.$1 ?? 0;
|
||||
final end =
|
||||
parsed?.$2 ?? source.length - 1;
|
||||
|
||||
final response = request.response;
|
||||
|
||||
response.statusCode = parsed == null
|
||||
? HttpStatus.ok
|
||||
: HttpStatus.partialContent;
|
||||
|
||||
response.headers.set(
|
||||
'accept-ranges',
|
||||
'bytes',
|
||||
);
|
||||
|
||||
response.headers.set(
|
||||
HttpHeaders.cacheControlHeader,
|
||||
'no-store',
|
||||
);
|
||||
|
||||
response.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
source.contentType,
|
||||
);
|
||||
|
||||
final responseLength =
|
||||
end - start + 1;
|
||||
|
||||
response.contentLength =
|
||||
responseLength;
|
||||
|
||||
if (parsed != null) {
|
||||
response.headers.set(
|
||||
'content-range',
|
||||
'bytes $start-$end/${source.length}',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] ${request.uri.path} '
|
||||
'${request.method} bytes=$start-$end',
|
||||
);
|
||||
|
||||
if (request.method == 'HEAD') {
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _streamGrowingFile(
|
||||
source: source,
|
||||
start: start,
|
||||
end: end,
|
||||
response: response,
|
||||
);
|
||||
} on Object catch (error) {
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.15] ${request.uri.path} échec: $error',
|
||||
);
|
||||
|
||||
try {
|
||||
await response.close();
|
||||
} catch (_) {
|
||||
// libmpv peut fermer une requête lors d'un seek.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _streamGrowingFile({
|
||||
required _GrowingSource source,
|
||||
required int start,
|
||||
required int end,
|
||||
required HttpResponse response,
|
||||
}) async {
|
||||
var cursor = start;
|
||||
RandomAccessFile? raf;
|
||||
|
||||
try {
|
||||
while (cursor <= end) {
|
||||
final available =
|
||||
await _safeLength(source.file);
|
||||
|
||||
if (available > cursor) {
|
||||
raf ??=
|
||||
await source.file.open(
|
||||
mode: FileMode.read,
|
||||
);
|
||||
|
||||
final readable = math.min(
|
||||
available - cursor,
|
||||
end - cursor + 1,
|
||||
);
|
||||
|
||||
final count = math.min(
|
||||
readable,
|
||||
512 * 1024,
|
||||
);
|
||||
|
||||
await raf.setPosition(cursor);
|
||||
|
||||
final data =
|
||||
await raf.read(count);
|
||||
|
||||
if (data.isEmpty) {
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 30),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
response.add(data);
|
||||
await response.flush();
|
||||
|
||||
cursor += data.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
final exitCode = source.exitCode;
|
||||
|
||||
if (exitCode != null) {
|
||||
if (exitCode != 0) {
|
||||
final stderr = source.stderr
|
||||
.toString()
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
throw HttpException(
|
||||
'yt-dlp ${source.id} code=$exitCode'
|
||||
'${stderr.isEmpty ? '' : ': $stderr'}',
|
||||
);
|
||||
}
|
||||
|
||||
final finalLength =
|
||||
await _safeLength(source.file);
|
||||
|
||||
if (finalLength <= cursor) {
|
||||
throw HttpException(
|
||||
'Cache ${source.id} terminé à '
|
||||
'$finalLength/${source.length} octets, '
|
||||
'mais mpv demande l’offset $cursor.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Le téléchargement est encore en cours et n'a pas atteint la zone.
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 35),
|
||||
);
|
||||
}
|
||||
|
||||
await response.close();
|
||||
} finally {
|
||||
await raf?.close();
|
||||
}
|
||||
}
|
||||
|
||||
(int, int)? _parseRange(
|
||||
String? value,
|
||||
int totalLength,
|
||||
) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final match = RegExp(
|
||||
r'^bytes=(\d*)-(\d*)$',
|
||||
caseSensitive: false,
|
||||
).firstMatch(value.trim());
|
||||
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final startText = match.group(1) ?? '';
|
||||
final endText = match.group(2) ?? '';
|
||||
|
||||
if (startText.isEmpty) {
|
||||
final suffix = int.tryParse(endText);
|
||||
|
||||
if (suffix == null || suffix <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final start = math.max(
|
||||
0,
|
||||
totalLength - suffix,
|
||||
);
|
||||
|
||||
return (start, totalLength - 1);
|
||||
}
|
||||
|
||||
final start = int.tryParse(startText);
|
||||
|
||||
if (start == null ||
|
||||
start < 0 ||
|
||||
start >= totalLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsedEnd = endText.isEmpty
|
||||
? totalLength - 1
|
||||
: int.tryParse(endText);
|
||||
|
||||
if (parsedEnd == null ||
|
||||
parsedEnd < start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final end = math.min(
|
||||
parsedEnd,
|
||||
totalLength - 1,
|
||||
);
|
||||
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
Future<int> _safeLength(
|
||||
File file,
|
||||
) async {
|
||||
try {
|
||||
if (!await file.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await file.length();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _GrowingSource {
|
||||
_GrowingSource({
|
||||
required this.id,
|
||||
required this.file,
|
||||
required this.formatId,
|
||||
required this.length,
|
||||
required this.contentType,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final File file;
|
||||
final String formatId;
|
||||
final int length;
|
||||
final String contentType;
|
||||
|
||||
Process? process;
|
||||
int? exitCode;
|
||||
final Completer<int> done =
|
||||
Completer<int>();
|
||||
final StringBuffer stderr =
|
||||
StringBuffer();
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'media_cache_settings_service.dart';
|
||||
|
||||
typedef WindowsDownloadProgress = void Function(
|
||||
String track,
|
||||
double percent,
|
||||
);
|
||||
|
||||
class WindowsEmbeddedLocalMedia {
|
||||
const WindowsEmbeddedLocalMedia({
|
||||
required this.videoUri,
|
||||
required this.audioUri,
|
||||
required this.videoPath,
|
||||
required this.audioPath,
|
||||
required this.videoFromCache,
|
||||
required this.audioFromCache,
|
||||
});
|
||||
|
||||
final String videoUri;
|
||||
final String audioUri;
|
||||
final String videoPath;
|
||||
final String audioPath;
|
||||
final bool videoFromCache;
|
||||
final bool audioFromCache;
|
||||
}
|
||||
|
||||
/// Mode Windows fiable et plus rapide.
|
||||
///
|
||||
/// Le transport reste exactement celui validé en 17.16 : yt-dlp télécharge
|
||||
/// complètement les pistes, puis media_kit ouvre uniquement des fichiers
|
||||
/// locaux.
|
||||
///
|
||||
/// Optimisations 17.17 :
|
||||
/// - cache persistant vérifié sous LOCALAPPDATA ;
|
||||
/// - un fichier déjà téléchargé n'est jamais repris du réseau ;
|
||||
/// - vidéo & audio manquants sont téléchargés en parallèle ;
|
||||
/// - les anciennes vidéos sont automatiquement limitées aux 5 plus récentes ;
|
||||
/// - callback de progression pour que l'UI affiche l'avancement.
|
||||
class WindowsEmbeddedDownloadService {
|
||||
final Set<Process> _activeProcesses =
|
||||
<Process>{};
|
||||
|
||||
Future<WindowsEmbeddedLocalMedia> download({
|
||||
required String videoId,
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
required String videoFormatId,
|
||||
required String audioFormatId,
|
||||
required String videoExt,
|
||||
required String audioExt,
|
||||
required int expectedVideoLength,
|
||||
required int expectedAudioLength,
|
||||
WindowsDownloadProgress? onProgress,
|
||||
}) async {
|
||||
if (!Platform.isWindows) {
|
||||
throw UnsupportedError(
|
||||
'Le téléchargement web_embedded est réservé à Windows.',
|
||||
);
|
||||
}
|
||||
|
||||
await stop();
|
||||
|
||||
final ytDlp = File(ytDlpPath);
|
||||
final deno = File(denoPath);
|
||||
|
||||
if (!await ytDlp.exists()) {
|
||||
throw StateError(
|
||||
'yt-dlp.exe introuvable : $ytDlpPath',
|
||||
);
|
||||
}
|
||||
|
||||
if (!await deno.exists()) {
|
||||
throw StateError(
|
||||
'deno.exe introuvable : $denoPath',
|
||||
);
|
||||
}
|
||||
|
||||
final cacheSettings = MediaCacheSettingsService.instance;
|
||||
final cacheRoot = await cacheSettings.resolveCacheRoot();
|
||||
final safeVideoId = _safeName(videoId);
|
||||
|
||||
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
|
||||
|
||||
final videoDirectory = Directory(
|
||||
'${cacheRoot.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'$safeVideoId',
|
||||
);
|
||||
|
||||
await videoDirectory.create(
|
||||
recursive: true,
|
||||
);
|
||||
|
||||
final videoFile = File(
|
||||
'${videoDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'video_${_safeName(videoFormatId)}.$videoExt',
|
||||
);
|
||||
|
||||
final audioFile = File(
|
||||
'${videoDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'audio_${_safeName(audioFormatId)}.$audioExt',
|
||||
);
|
||||
|
||||
final videoHit = await _isValidCacheFile(
|
||||
videoFile,
|
||||
expectedVideoLength,
|
||||
);
|
||||
|
||||
final audioHit = await _isValidCacheFile(
|
||||
audioFile,
|
||||
expectedAudioLength,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'[HD-CACHE][17.17] '
|
||||
'video=${videoHit ? 'HIT' : 'MISS'} '
|
||||
'audio=${audioHit ? 'HIT' : 'MISS'} '
|
||||
'dossier=${videoDirectory.path}',
|
||||
);
|
||||
|
||||
if (videoHit) {
|
||||
onProgress?.call('video', 100);
|
||||
}
|
||||
|
||||
if (audioHit) {
|
||||
onProgress?.call('audio', 100);
|
||||
}
|
||||
|
||||
final futures = <Future<_DownloadResult>>[];
|
||||
|
||||
if (!videoHit) {
|
||||
await _deleteIfExists(videoFile);
|
||||
|
||||
futures.add(
|
||||
_downloadOne(
|
||||
id: 'video',
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
formatId: videoFormatId,
|
||||
outputFile: videoFile,
|
||||
expectedLength: expectedVideoLength,
|
||||
onProgress: onProgress,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!audioHit) {
|
||||
await _deleteIfExists(audioFile);
|
||||
|
||||
futures.add(
|
||||
_downloadOne(
|
||||
id: 'audio',
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
formatId: audioFormatId,
|
||||
outputFile: audioFile,
|
||||
expectedLength: expectedAudioLength,
|
||||
onProgress: onProgress,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (futures.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[HD-DOWNLOAD][17.17] téléchargement '
|
||||
'${videoHit ? '' : 'video=$videoFormatId '}'
|
||||
'${audioHit ? '' : 'audio=$audioFormatId'}',
|
||||
);
|
||||
|
||||
final results =
|
||||
await Future.wait<_DownloadResult>(
|
||||
futures,
|
||||
);
|
||||
|
||||
final failed = results
|
||||
.where((result) => !result.ok)
|
||||
.toList(growable: false);
|
||||
|
||||
if (failed.isNotEmpty) {
|
||||
throw StateError(
|
||||
failed.map((result) => result.error).join(' | '),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final videoLength = await _safeLength(videoFile);
|
||||
final audioLength = await _safeLength(audioFile);
|
||||
|
||||
if (!_lengthMatches(
|
||||
videoLength,
|
||||
expectedVideoLength,
|
||||
) ||
|
||||
!_lengthMatches(
|
||||
audioLength,
|
||||
expectedAudioLength,
|
||||
)) {
|
||||
throw StateError(
|
||||
'Cache final invalide: '
|
||||
'video=$videoLength/$expectedVideoLength '
|
||||
'audio=$audioLength/$expectedAudioLength',
|
||||
);
|
||||
}
|
||||
|
||||
// Marque ce cache comme récemment utilisé.
|
||||
//
|
||||
// On utilise un petit fichier marqueur dont la date de modification
|
||||
// sert de timestamp LRU pour le nettoyage des anciens caches.
|
||||
final accessMarker = File(
|
||||
'${videoDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'.last_access',
|
||||
);
|
||||
|
||||
try {
|
||||
await accessMarker.writeAsString(
|
||||
DateTime.now().toIso8601String(),
|
||||
flush: true,
|
||||
);
|
||||
} catch (_) {
|
||||
// Non bloquant.
|
||||
}
|
||||
|
||||
await cacheSettings.touchVideoDirectory(videoDirectory);
|
||||
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
|
||||
|
||||
debugPrint(
|
||||
'[HD-DOWNLOAD][17.17] prêts: '
|
||||
'video=$videoLength/$expectedVideoLength '
|
||||
'audio=$audioLength/$expectedAudioLength',
|
||||
);
|
||||
|
||||
return WindowsEmbeddedLocalMedia(
|
||||
videoUri: Uri.file(
|
||||
videoFile.path,
|
||||
windows: true,
|
||||
).toString(),
|
||||
audioUri: Uri.file(
|
||||
audioFile.path,
|
||||
windows: true,
|
||||
).toString(),
|
||||
videoPath: videoFile.path,
|
||||
audioPath: audioFile.path,
|
||||
videoFromCache: videoHit,
|
||||
audioFromCache: audioHit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_DownloadResult> _downloadOne({
|
||||
required String id,
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
required String formatId,
|
||||
required File outputFile,
|
||||
required int expectedLength,
|
||||
required WindowsDownloadProgress? onProgress,
|
||||
}) async {
|
||||
final args = <String>[
|
||||
'--ignore-config',
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--newline',
|
||||
'--socket-timeout',
|
||||
'20',
|
||||
'--retries',
|
||||
'5',
|
||||
'--fragment-retries',
|
||||
'5',
|
||||
'--js-runtimes',
|
||||
'deno:$denoPath',
|
||||
'--extractor-args',
|
||||
'youtube:player_client=web_embedded',
|
||||
'--force-overwrites',
|
||||
'--no-part',
|
||||
'-f',
|
||||
formatId,
|
||||
'-o',
|
||||
outputFile.path,
|
||||
watchUrl,
|
||||
];
|
||||
|
||||
final process = await Process.start(
|
||||
ytDlpPath,
|
||||
args,
|
||||
workingDirectory:
|
||||
File(ytDlpPath).parent.path,
|
||||
runInShell: false,
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
_activeProcesses.add(process);
|
||||
|
||||
final stderr = StringBuffer();
|
||||
var lastLoggedBucket = -1;
|
||||
|
||||
final stdoutSubscription = process.stdout
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(text) {
|
||||
for (final line
|
||||
in text.split(RegExp(r'[\r\n]+'))) {
|
||||
final match = RegExp(
|
||||
r'\[download\]\s+(\d+(?:\.\d+)?)%',
|
||||
).firstMatch(line);
|
||||
|
||||
if (match == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final value = double.tryParse(
|
||||
match.group(1) ?? '',
|
||||
);
|
||||
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
onProgress?.call(id, value);
|
||||
|
||||
final bucket = value >= 100
|
||||
? 10
|
||||
: value.floor() ~/ 10;
|
||||
|
||||
if (bucket > lastLoggedBucket) {
|
||||
lastLoggedBucket = bucket;
|
||||
|
||||
debugPrint(
|
||||
'[HD-DOWNLOAD][17.17] '
|
||||
'$id ${value.toStringAsFixed(1)}%',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final stderrSubscription = process.stderr
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(text) {
|
||||
if (stderr.length < 5000) {
|
||||
final remaining =
|
||||
5000 - stderr.length;
|
||||
|
||||
stderr.write(
|
||||
text.length <= remaining
|
||||
? text
|
||||
: text.substring(0, remaining),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final exitCode = await process.exitCode;
|
||||
|
||||
await stdoutSubscription.cancel();
|
||||
await stderrSubscription.cancel();
|
||||
|
||||
_activeProcesses.remove(process);
|
||||
|
||||
final actualLength =
|
||||
await _safeLength(outputFile);
|
||||
|
||||
if (exitCode != 0) {
|
||||
final compact = stderr
|
||||
.toString()
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
await _deleteIfExists(outputFile);
|
||||
|
||||
return _DownloadResult(
|
||||
ok: false,
|
||||
error:
|
||||
'$id yt-dlp code=$exitCode'
|
||||
'${compact.isEmpty ? '' : ': $compact'}',
|
||||
);
|
||||
}
|
||||
|
||||
if (!_lengthMatches(
|
||||
actualLength,
|
||||
expectedLength,
|
||||
)) {
|
||||
await _deleteIfExists(outputFile);
|
||||
|
||||
return _DownloadResult(
|
||||
ok: false,
|
||||
error:
|
||||
'$id taille invalide '
|
||||
'$actualLength/$expectedLength',
|
||||
);
|
||||
}
|
||||
|
||||
onProgress?.call(id, 100);
|
||||
|
||||
debugPrint(
|
||||
'[HD-DOWNLOAD][17.17] $id terminé: '
|
||||
'$actualLength/$expectedLength octets',
|
||||
);
|
||||
|
||||
return const _DownloadResult(
|
||||
ok: true,
|
||||
error: '',
|
||||
);
|
||||
}
|
||||
|
||||
String _safeName(String value) {
|
||||
final safe = value.replaceAll(
|
||||
RegExp(r'[^A-Za-z0-9._-]'),
|
||||
'_',
|
||||
);
|
||||
|
||||
return safe.isEmpty ? 'media' : safe;
|
||||
}
|
||||
|
||||
bool _lengthMatches(
|
||||
int actual,
|
||||
int expected,
|
||||
) {
|
||||
if (actual <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expected <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return actual == expected;
|
||||
}
|
||||
|
||||
Future<bool> _isValidCacheFile(
|
||||
File file,
|
||||
int expectedLength,
|
||||
) async {
|
||||
final length = await _safeLength(file);
|
||||
|
||||
if (_lengthMatches(
|
||||
length,
|
||||
expectedLength,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (length > 0) {
|
||||
await _deleteIfExists(file);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<int> _safeLength(
|
||||
File file,
|
||||
) async {
|
||||
try {
|
||||
if (!await file.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await file.length();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteIfExists(
|
||||
File file,
|
||||
) async {
|
||||
try {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Le prochain téléchargement donnera une erreur explicite si besoin.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final processes =
|
||||
_activeProcesses.toList(growable: false);
|
||||
|
||||
_activeProcesses.clear();
|
||||
|
||||
for (final process in processes) {
|
||||
process.kill();
|
||||
}
|
||||
|
||||
if (processes.isNotEmpty) {
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 120),
|
||||
);
|
||||
}
|
||||
|
||||
// Important : le cache persistant n'est PAS supprimé ici.
|
||||
// C'est ce qui rend une seconde lecture quasi immédiate.
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadResult {
|
||||
const _DownloadResult({
|
||||
required this.ok,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
final bool ok;
|
||||
final String error;
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'media_cache_settings_service.dart';
|
||||
|
||||
class WindowsEmbeddedProgressiveSession {
|
||||
const WindowsEmbeddedProgressiveSession({
|
||||
required this.videoUrl,
|
||||
required this.audioUri,
|
||||
required this.videoFromCache,
|
||||
required this.audioFromCache,
|
||||
});
|
||||
|
||||
final String videoUrl;
|
||||
final String audioUri;
|
||||
final bool videoFromCache;
|
||||
final bool audioFromCache;
|
||||
}
|
||||
|
||||
class WindowsEmbeddedProgressiveStatus {
|
||||
const WindowsEmbeddedProgressiveStatus({
|
||||
required this.videoBytes,
|
||||
required this.videoLength,
|
||||
required this.audioBytes,
|
||||
required this.audioLength,
|
||||
required this.videoComplete,
|
||||
required this.audioComplete,
|
||||
required this.estimatedBufferedDuration,
|
||||
});
|
||||
|
||||
final int videoBytes;
|
||||
final int videoLength;
|
||||
final int audioBytes;
|
||||
final int audioLength;
|
||||
final bool videoComplete;
|
||||
final bool audioComplete;
|
||||
final Duration estimatedBufferedDuration;
|
||||
}
|
||||
|
||||
/// Streaming progressif Windows basé sur le transport déjà validé yt-dlp.
|
||||
///
|
||||
/// yt-dlp télécharge le fichier web_embedded pendant que libmpv lit une
|
||||
/// façade HTTP locale du fichier qui grandit.
|
||||
///
|
||||
/// Différence critique avec l'étape 17.15 :
|
||||
/// on N'OUVRE PLUS libmpv lorsque le fichier fait 0 octet. Le lecteur 360p
|
||||
/// reste actif jusqu'à ce qu'une vraie réserve HD soit disponible autour du
|
||||
/// timestamp courant.
|
||||
///
|
||||
/// Les fichiers restent dans le cache persistant :
|
||||
/// %LOCALAPPDATA%\YouTubePersonal\media_cache\<videoId>
|
||||
class WindowsEmbeddedProgressiveService {
|
||||
HttpServer? _server;
|
||||
StreamSubscription<HttpRequest>? _serverSubscription;
|
||||
|
||||
final Set<Process> _activeProcesses =
|
||||
<Process>{};
|
||||
|
||||
_GrowingSource? _video;
|
||||
_GrowingSource? _audio;
|
||||
String? _activeVideoCacheId;
|
||||
Directory? _activeVideoDirectory;
|
||||
|
||||
Future<WindowsEmbeddedProgressiveSession> start({
|
||||
required String videoId,
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
required String videoFormatId,
|
||||
required String audioFormatId,
|
||||
required String videoExt,
|
||||
required String audioExt,
|
||||
required String videoMimeType,
|
||||
required int videoLength,
|
||||
required int audioLength,
|
||||
}) async {
|
||||
if (!Platform.isWindows) {
|
||||
throw UnsupportedError(
|
||||
'Le streaming progressif HD est réservé à Windows.',
|
||||
);
|
||||
}
|
||||
|
||||
await stop();
|
||||
|
||||
final ytDlp = File(ytDlpPath);
|
||||
final deno = File(denoPath);
|
||||
|
||||
if (!await ytDlp.exists()) {
|
||||
throw StateError(
|
||||
'yt-dlp.exe introuvable : $ytDlpPath',
|
||||
);
|
||||
}
|
||||
|
||||
if (!await deno.exists()) {
|
||||
throw StateError(
|
||||
'deno.exe introuvable : $denoPath',
|
||||
);
|
||||
}
|
||||
|
||||
if (videoLength <= 0 || audioLength <= 0) {
|
||||
throw StateError(
|
||||
'Tailles web_embedded invalides : '
|
||||
'video=$videoLength audio=$audioLength',
|
||||
);
|
||||
}
|
||||
|
||||
final cacheSettings = MediaCacheSettingsService.instance;
|
||||
final cacheRoot = await cacheSettings.resolveCacheRoot();
|
||||
final safeVideoId = _safeName(videoId);
|
||||
|
||||
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
|
||||
|
||||
final videoDirectory = Directory(
|
||||
'${cacheRoot.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'$safeVideoId',
|
||||
);
|
||||
|
||||
await videoDirectory.create(
|
||||
recursive: true,
|
||||
);
|
||||
_activeVideoCacheId = safeVideoId;
|
||||
_activeVideoDirectory = videoDirectory;
|
||||
await cacheSettings.touchVideoDirectory(videoDirectory);
|
||||
|
||||
final videoFile = File(
|
||||
'${videoDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'video_${_safeName(videoFormatId)}.$videoExt',
|
||||
);
|
||||
|
||||
final audioFile = File(
|
||||
'${videoDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'audio_${_safeName(audioFormatId)}.$audioExt',
|
||||
);
|
||||
|
||||
final videoHit =
|
||||
await _isComplete(
|
||||
videoFile,
|
||||
videoLength,
|
||||
);
|
||||
|
||||
final audioHit =
|
||||
await _isComplete(
|
||||
audioFile,
|
||||
audioLength,
|
||||
);
|
||||
|
||||
// Un ancien fichier incomplet ne doit pas être pris pour un HIT.
|
||||
// On le recrée proprement; yt-dlp continuera ensuite à le remplir
|
||||
// pendant la lecture.
|
||||
if (!videoHit) {
|
||||
await _deleteIfExists(videoFile);
|
||||
}
|
||||
|
||||
if (!audioHit) {
|
||||
await _deleteIfExists(audioFile);
|
||||
}
|
||||
|
||||
final video = _GrowingSource(
|
||||
id: 'video',
|
||||
file: videoFile,
|
||||
expectedLength: videoLength,
|
||||
formatId: videoFormatId,
|
||||
);
|
||||
|
||||
final audio = _GrowingSource(
|
||||
id: 'audio',
|
||||
file: audioFile,
|
||||
expectedLength: audioLength,
|
||||
formatId: audioFormatId,
|
||||
);
|
||||
|
||||
if (videoHit) {
|
||||
video.exitCode = 0;
|
||||
}
|
||||
|
||||
if (audioHit) {
|
||||
audio.exitCode = 0;
|
||||
}
|
||||
|
||||
_video = video;
|
||||
_audio = audio;
|
||||
|
||||
final server = await HttpServer.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
shared: false,
|
||||
);
|
||||
|
||||
_server = server;
|
||||
|
||||
_serverSubscription = server.listen(
|
||||
(request) {
|
||||
unawaited(
|
||||
_handleVideoRequest(
|
||||
request,
|
||||
video,
|
||||
videoMimeType,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] serveur local: $error',
|
||||
);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
if (!videoHit) {
|
||||
await _startDownload(
|
||||
source: video,
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (!audioHit) {
|
||||
await _startDownload(
|
||||
source: audio,
|
||||
ytDlpPath: ytDlp.path,
|
||||
denoPath: deno.path,
|
||||
watchUrl: watchUrl,
|
||||
);
|
||||
}
|
||||
|
||||
final base =
|
||||
'http://127.0.0.1:${server.port}';
|
||||
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] session prête '
|
||||
'video=${videoHit ? 'HIT' : 'STREAM'} '
|
||||
'audio=${audioHit ? 'HIT' : 'DOWNLOAD'} '
|
||||
'video=$videoFormatId audio=$audioFormatId',
|
||||
);
|
||||
|
||||
return WindowsEmbeddedProgressiveSession(
|
||||
videoUrl: '$base/video',
|
||||
audioUri: Uri.file(
|
||||
audioFile.path,
|
||||
windows: true,
|
||||
).toString(),
|
||||
videoFromCache: videoHit,
|
||||
audioFromCache: audioHit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<WindowsEmbeddedProgressiveStatus> status({
|
||||
required Duration mediaDuration,
|
||||
}) async {
|
||||
final video = _video;
|
||||
final audio = _audio;
|
||||
|
||||
if (video == null || audio == null) {
|
||||
throw StateError(
|
||||
'Aucune session progressive active.',
|
||||
);
|
||||
}
|
||||
|
||||
final videoBytes =
|
||||
await _safeLength(video.file);
|
||||
|
||||
final audioBytes =
|
||||
await _safeLength(audio.file);
|
||||
|
||||
final videoComplete =
|
||||
videoBytes == video.expectedLength;
|
||||
|
||||
final audioComplete =
|
||||
audioBytes == audio.expectedLength;
|
||||
|
||||
Duration estimated = Duration.zero;
|
||||
|
||||
if (mediaDuration > Duration.zero &&
|
||||
video.expectedLength > 0 &&
|
||||
videoBytes > 0) {
|
||||
final ratio = (
|
||||
videoBytes / video.expectedLength
|
||||
).clamp(0.0, 1.0);
|
||||
|
||||
estimated = Duration(
|
||||
milliseconds:
|
||||
(mediaDuration.inMilliseconds * ratio)
|
||||
.round(),
|
||||
);
|
||||
}
|
||||
|
||||
return WindowsEmbeddedProgressiveStatus(
|
||||
videoBytes: videoBytes,
|
||||
videoLength: video.expectedLength,
|
||||
audioBytes: audioBytes,
|
||||
audioLength: audio.expectedLength,
|
||||
videoComplete: videoComplete,
|
||||
audioComplete: audioComplete,
|
||||
estimatedBufferedDuration: estimated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vrai lorsque le fichier vidéo contient suffisamment d'octets pour
|
||||
/// couvrir approximativement [position] + [ahead], avec une marge.
|
||||
///
|
||||
/// On n'utilise pas --download-sections ici : le fichier DASH continue de
|
||||
/// grandir normalement, ce qui évite une découpe/re-encapsulation ffmpeg.
|
||||
Future<bool> hasEnoughBuffer({
|
||||
required Duration position,
|
||||
required Duration mediaDuration,
|
||||
Duration ahead = const Duration(seconds: 20),
|
||||
}) async {
|
||||
final video = _video;
|
||||
final audio = _audio;
|
||||
|
||||
if (video == null || audio == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final videoBytes =
|
||||
await _safeLength(video.file);
|
||||
|
||||
final audioBytes =
|
||||
await _safeLength(audio.file);
|
||||
|
||||
// L'audio 251 est petit. Pour garantir le son lors de la bascule,
|
||||
// on attend qu'il soit complet.
|
||||
if (audioBytes != audio.expectedLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoBytes == video.expectedLength) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mediaDuration <= Duration.zero) {
|
||||
return videoBytes >= 24 * 1024 * 1024;
|
||||
}
|
||||
|
||||
final wantedPosition = Duration(
|
||||
milliseconds: math.min(
|
||||
mediaDuration.inMilliseconds,
|
||||
position.inMilliseconds +
|
||||
ahead.inMilliseconds,
|
||||
),
|
||||
);
|
||||
|
||||
final ratio = (
|
||||
wantedPosition.inMilliseconds /
|
||||
mediaDuration.inMilliseconds
|
||||
).clamp(0.0, 1.0);
|
||||
|
||||
// 6 MiB de marge pour l'init/index + variations de bitrate.
|
||||
final estimatedRequired =
|
||||
(video.expectedLength * ratio).ceil() +
|
||||
6 * 1024 * 1024;
|
||||
|
||||
// Même au début, on exige un prébuffer réel pour ne pas reproduire
|
||||
// l'échec 17.15 où mpv ouvrait le flux alors qu'il était vide.
|
||||
final required = math.min(
|
||||
video.expectedLength,
|
||||
math.max(
|
||||
16 * 1024 * 1024,
|
||||
estimatedRequired,
|
||||
),
|
||||
);
|
||||
|
||||
return videoBytes >= required;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final subscription =
|
||||
_serverSubscription;
|
||||
_serverSubscription = null;
|
||||
|
||||
if (subscription != null) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
final server = _server;
|
||||
_server = null;
|
||||
|
||||
if (server != null) {
|
||||
await server.close(
|
||||
force: true,
|
||||
);
|
||||
}
|
||||
|
||||
final processes =
|
||||
_activeProcesses.toList(
|
||||
growable: false,
|
||||
);
|
||||
|
||||
_activeProcesses.clear();
|
||||
|
||||
for (final process in processes) {
|
||||
process.kill();
|
||||
}
|
||||
|
||||
if (processes.isNotEmpty) {
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 150),
|
||||
);
|
||||
}
|
||||
|
||||
// Les fichiers COMPLETS restent en cache.
|
||||
// Les fichiers incomplets sont supprimés pour éviter un faux HIT.
|
||||
final video = _video;
|
||||
final audio = _audio;
|
||||
|
||||
_video = null;
|
||||
_audio = null;
|
||||
|
||||
if (video != null) {
|
||||
final length =
|
||||
await _safeLength(video.file);
|
||||
|
||||
if (length != video.expectedLength) {
|
||||
await _deleteIfExists(video.file);
|
||||
}
|
||||
}
|
||||
|
||||
if (audio != null) {
|
||||
final length =
|
||||
await _safeLength(audio.file);
|
||||
|
||||
if (length != audio.expectedLength) {
|
||||
await _deleteIfExists(audio.file);
|
||||
}
|
||||
}
|
||||
|
||||
final cacheId = _activeVideoCacheId;
|
||||
final cacheDirectory = _activeVideoDirectory;
|
||||
_activeVideoCacheId = null;
|
||||
_activeVideoDirectory = null;
|
||||
|
||||
if (cacheDirectory != null) {
|
||||
final cacheSettings = MediaCacheSettingsService.instance;
|
||||
await cacheSettings.touchVideoDirectory(cacheDirectory);
|
||||
await cacheSettings.enforceLimit(keepVideoId: cacheId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startDownload({
|
||||
required _GrowingSource source,
|
||||
required String ytDlpPath,
|
||||
required String denoPath,
|
||||
required String watchUrl,
|
||||
}) async {
|
||||
final args = <String>[
|
||||
'--ignore-config',
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--newline',
|
||||
'--socket-timeout',
|
||||
'20',
|
||||
'--retries',
|
||||
'5',
|
||||
'--fragment-retries',
|
||||
'5',
|
||||
'--js-runtimes',
|
||||
'deno:$denoPath',
|
||||
'--extractor-args',
|
||||
'youtube:player_client=web_embedded',
|
||||
'--force-overwrites',
|
||||
'--no-part',
|
||||
'-f',
|
||||
source.formatId,
|
||||
'-o',
|
||||
source.file.path,
|
||||
watchUrl,
|
||||
];
|
||||
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] yt-dlp '
|
||||
'${source.id} format=${source.formatId}',
|
||||
);
|
||||
|
||||
final process = await Process.start(
|
||||
ytDlpPath,
|
||||
args,
|
||||
workingDirectory:
|
||||
File(ytDlpPath).parent.path,
|
||||
runInShell: false,
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
source.process = process;
|
||||
_activeProcesses.add(process);
|
||||
|
||||
process.stdout
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(_) {
|
||||
// Le player affiche l'état à partir de la taille réelle du fichier,
|
||||
// plus fiable que les lignes de progression yt-dlp.
|
||||
},
|
||||
);
|
||||
|
||||
process.stderr
|
||||
.transform(
|
||||
const SystemEncoding().decoder,
|
||||
)
|
||||
.listen(
|
||||
(text) {
|
||||
if (source.stderr.length < 5000) {
|
||||
final remaining =
|
||||
5000 - source.stderr.length;
|
||||
|
||||
source.stderr.write(
|
||||
text.length <= remaining
|
||||
? text
|
||||
: text.substring(0, remaining),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(
|
||||
process.exitCode.then(
|
||||
(code) async {
|
||||
_activeProcesses.remove(process);
|
||||
source.exitCode = code;
|
||||
|
||||
final length =
|
||||
await _safeLength(source.file);
|
||||
|
||||
if (code == 0 &&
|
||||
length == source.expectedLength) {
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] '
|
||||
'${source.id} complet: '
|
||||
'$length/${source.expectedLength}',
|
||||
);
|
||||
} else {
|
||||
final stderr = source.stderr
|
||||
.toString()
|
||||
.replaceAll(
|
||||
RegExp(r'\s+'),
|
||||
' ',
|
||||
)
|
||||
.trim();
|
||||
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] '
|
||||
'${source.id} fin code=$code '
|
||||
'$length/${source.expectedLength}'
|
||||
'${stderr.isEmpty ? '' : ': $stderr'}',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleVideoRequest(
|
||||
HttpRequest request,
|
||||
_GrowingSource source,
|
||||
String contentType,
|
||||
) async {
|
||||
if (request.uri.path != '/video') {
|
||||
request.response.statusCode =
|
||||
HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method != 'GET' &&
|
||||
request.method != 'HEAD') {
|
||||
request.response.statusCode =
|
||||
HttpStatus.methodNotAllowed;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final rangeHeader =
|
||||
request.headers.value(
|
||||
HttpHeaders.rangeHeader,
|
||||
);
|
||||
|
||||
final parsed = _parseRange(
|
||||
rangeHeader,
|
||||
source.expectedLength,
|
||||
);
|
||||
|
||||
if (rangeHeader != null &&
|
||||
parsed == null) {
|
||||
request.response.statusCode =
|
||||
HttpStatus.requestedRangeNotSatisfiable;
|
||||
|
||||
request.response.headers.set(
|
||||
'content-range',
|
||||
'bytes */${source.expectedLength}',
|
||||
);
|
||||
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final start = parsed?.$1 ?? 0;
|
||||
final end =
|
||||
parsed?.$2 ??
|
||||
source.expectedLength - 1;
|
||||
|
||||
// Ne pas envoyer de réponse vide à mpv.
|
||||
// Si mpv demande directement un offset légèrement au-delà du buffer
|
||||
// estimé, on attend que yt-dlp atteigne réellement cet offset.
|
||||
await _waitForOffset(
|
||||
source,
|
||||
start,
|
||||
);
|
||||
|
||||
final response = request.response;
|
||||
|
||||
response.statusCode = parsed == null
|
||||
? HttpStatus.ok
|
||||
: HttpStatus.partialContent;
|
||||
|
||||
response.headers.set(
|
||||
'accept-ranges',
|
||||
'bytes',
|
||||
);
|
||||
|
||||
response.headers.set(
|
||||
HttpHeaders.cacheControlHeader,
|
||||
'no-store',
|
||||
);
|
||||
|
||||
response.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
contentType,
|
||||
);
|
||||
|
||||
response.contentLength =
|
||||
end - start + 1;
|
||||
|
||||
if (parsed != null) {
|
||||
response.headers.set(
|
||||
'content-range',
|
||||
'bytes $start-$end/'
|
||||
'${source.expectedLength}',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] /video '
|
||||
'${request.method} bytes=$start-$end',
|
||||
);
|
||||
|
||||
if (request.method == 'HEAD') {
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _streamGrowingFile(
|
||||
source: source,
|
||||
start: start,
|
||||
end: end,
|
||||
response: response,
|
||||
);
|
||||
} on Object catch (error) {
|
||||
debugPrint(
|
||||
'[HD-PROGRESSIVE][17.19] /video: $error',
|
||||
);
|
||||
|
||||
try {
|
||||
await response.close();
|
||||
} catch (_) {
|
||||
// mpv peut fermer la connexion lors d'un seek/changement source.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForOffset(
|
||||
_GrowingSource source,
|
||||
int offset,
|
||||
) async {
|
||||
final deadline =
|
||||
DateTime.now().add(
|
||||
const Duration(seconds: 45),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
final available =
|
||||
await _safeLength(source.file);
|
||||
|
||||
if (available > offset ||
|
||||
available == source.expectedLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
final exitCode = source.exitCode;
|
||||
|
||||
if (exitCode != null &&
|
||||
exitCode != 0) {
|
||||
throw HttpException(
|
||||
'yt-dlp ${source.id} code=$exitCode',
|
||||
);
|
||||
}
|
||||
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
throw TimeoutException(
|
||||
'Offset $offset non disponible '
|
||||
'après 45 s.',
|
||||
);
|
||||
}
|
||||
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 40),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _streamGrowingFile({
|
||||
required _GrowingSource source,
|
||||
required int start,
|
||||
required int end,
|
||||
required HttpResponse response,
|
||||
}) async {
|
||||
var cursor = start;
|
||||
RandomAccessFile? raf;
|
||||
|
||||
try {
|
||||
while (cursor <= end) {
|
||||
final available =
|
||||
await _safeLength(source.file);
|
||||
|
||||
if (available > cursor) {
|
||||
raf ??=
|
||||
await source.file.open(
|
||||
mode: FileMode.read,
|
||||
);
|
||||
|
||||
final readable = math.min(
|
||||
available - cursor,
|
||||
end - cursor + 1,
|
||||
);
|
||||
|
||||
final count = math.min(
|
||||
readable,
|
||||
512 * 1024,
|
||||
);
|
||||
|
||||
await raf.setPosition(cursor);
|
||||
|
||||
final data =
|
||||
await raf.read(count);
|
||||
|
||||
if (data.isEmpty) {
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 20),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
response.add(data);
|
||||
await response.flush();
|
||||
|
||||
cursor += data.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
final exitCode = source.exitCode;
|
||||
|
||||
if (exitCode != null) {
|
||||
if (exitCode != 0) {
|
||||
throw HttpException(
|
||||
'yt-dlp ${source.id} code=$exitCode',
|
||||
);
|
||||
}
|
||||
|
||||
if (available <= cursor) {
|
||||
throw HttpException(
|
||||
'EOF progressif à $available, '
|
||||
'offset demandé=$cursor.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Le Player a rejoint le bord du téléchargement.
|
||||
// On attend que yt-dlp reprenne de l'avance.
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 30),
|
||||
);
|
||||
}
|
||||
|
||||
await response.close();
|
||||
} finally {
|
||||
await raf?.close();
|
||||
}
|
||||
}
|
||||
|
||||
(int, int)? _parseRange(
|
||||
String? value,
|
||||
int totalLength,
|
||||
) {
|
||||
if (value == null ||
|
||||
value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final match = RegExp(
|
||||
r'^bytes=(\d*)-(\d*)$',
|
||||
caseSensitive: false,
|
||||
).firstMatch(
|
||||
value.trim(),
|
||||
);
|
||||
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final startText =
|
||||
match.group(1) ?? '';
|
||||
final endText =
|
||||
match.group(2) ?? '';
|
||||
|
||||
if (startText.isEmpty) {
|
||||
final suffix =
|
||||
int.tryParse(endText);
|
||||
|
||||
if (suffix == null ||
|
||||
suffix <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final start = math.max(
|
||||
0,
|
||||
totalLength - suffix,
|
||||
);
|
||||
|
||||
return (
|
||||
start,
|
||||
totalLength - 1,
|
||||
);
|
||||
}
|
||||
|
||||
final start =
|
||||
int.tryParse(startText);
|
||||
|
||||
if (start == null ||
|
||||
start < 0 ||
|
||||
start >= totalLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsedEnd =
|
||||
endText.isEmpty
|
||||
? totalLength - 1
|
||||
: int.tryParse(endText);
|
||||
|
||||
if (parsedEnd == null ||
|
||||
parsedEnd < start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
start,
|
||||
math.min(
|
||||
parsedEnd,
|
||||
totalLength - 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _safeName(
|
||||
String value,
|
||||
) {
|
||||
final safe =
|
||||
value.replaceAll(
|
||||
RegExp(r'[^A-Za-z0-9._-]'),
|
||||
'_',
|
||||
);
|
||||
|
||||
return safe.isEmpty
|
||||
? 'media'
|
||||
: safe;
|
||||
}
|
||||
|
||||
Future<bool> _isComplete(
|
||||
File file,
|
||||
int expectedLength,
|
||||
) async {
|
||||
return await _safeLength(file) ==
|
||||
expectedLength;
|
||||
}
|
||||
|
||||
Future<int> _safeLength(
|
||||
File file,
|
||||
) async {
|
||||
try {
|
||||
if (!await file.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await file.length();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteIfExists(
|
||||
File file,
|
||||
) async {
|
||||
try {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Une erreur ultérieure sera plus explicite.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _GrowingSource {
|
||||
_GrowingSource({
|
||||
required this.id,
|
||||
required this.file,
|
||||
required this.expectedLength,
|
||||
required this.formatId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final File file;
|
||||
final int expectedLength;
|
||||
final String formatId;
|
||||
|
||||
Process? process;
|
||||
int? exitCode;
|
||||
final StringBuffer stderr =
|
||||
StringBuffer();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class WindowsRelaySession {
|
||||
const WindowsRelaySession({
|
||||
required this.videoUrl,
|
||||
required this.audioUrl,
|
||||
});
|
||||
|
||||
final String videoUrl;
|
||||
final String audioUrl;
|
||||
}
|
||||
|
||||
/// Relais HTTP local Windows dont le transport upstream est exécuté par
|
||||
/// yt-dlp lui-même.
|
||||
///
|
||||
/// Architecture :
|
||||
///
|
||||
/// libmpv -> 127.0.0.1 -> yt-dlp HttpFD -> googlevideo
|
||||
///
|
||||
/// Les étapes 17.10/17.11 ont démontré que :
|
||||
/// - libmpv lit correctement le 1080p depuis localhost ;
|
||||
/// - le HttpClient Dart n'est pas accepté de façon stable par Googlevideo ;
|
||||
/// - yt-dlp, lui, sait télécharger le même format.
|
||||
///
|
||||
/// Pour chaque GET/Range demandé par mpv, le relais construit un petit
|
||||
/// info-json local contenant l'URL signée, les headers et la plage demandée,
|
||||
/// puis lance le yt-dlp déjà empaqueté avec `--load-info-json` et `-o -`.
|
||||
///
|
||||
/// yt-dlp applique alors exactement son propre downloader HTTP, y compris
|
||||
/// la logique officielle de reprise/chunking et son stack réseau.
|
||||
class WindowsMediaRelayService {
|
||||
HttpServer? _server;
|
||||
StreamSubscription<HttpRequest>? _serverSubscription;
|
||||
|
||||
final Map<String, _RelaySource> _sources =
|
||||
<String, _RelaySource>{};
|
||||
|
||||
final Set<Process> _activeProcesses =
|
||||
<Process>{};
|
||||
|
||||
Directory? _sessionDirectory;
|
||||
|
||||
Future<WindowsRelaySession> start({
|
||||
required String ytDlpPath,
|
||||
required String videoUrl,
|
||||
required String audioUrl,
|
||||
required Map<String, String> videoHeaders,
|
||||
required Map<String, String> audioHeaders,
|
||||
required int videoLength,
|
||||
required int audioLength,
|
||||
required int videoChunkSize,
|
||||
required int audioChunkSize,
|
||||
required String videoMimeType,
|
||||
required String audioMimeType,
|
||||
}) async {
|
||||
if (!Platform.isWindows) {
|
||||
throw UnsupportedError(
|
||||
'Le relais HD local est réservé à Windows.',
|
||||
);
|
||||
}
|
||||
|
||||
await stop();
|
||||
|
||||
final ytDlp = File(ytDlpPath);
|
||||
|
||||
if (!await ytDlp.exists()) {
|
||||
throw StateError(
|
||||
'yt-dlp.exe introuvable pour le relais : $ytDlpPath',
|
||||
);
|
||||
}
|
||||
|
||||
if (videoLength <= 0 || audioLength <= 0) {
|
||||
throw StateError(
|
||||
'Taille vidéo/audio inconnue : '
|
||||
'video=$videoLength audio=$audioLength',
|
||||
);
|
||||
}
|
||||
|
||||
final videoUri = Uri.tryParse(videoUrl);
|
||||
final audioUri = Uri.tryParse(audioUrl);
|
||||
|
||||
if (videoUri == null ||
|
||||
!videoUri.hasScheme ||
|
||||
audioUri == null ||
|
||||
!audioUri.hasScheme) {
|
||||
throw StateError(
|
||||
'URL vidéo/audio invalide pour le relais.',
|
||||
);
|
||||
}
|
||||
|
||||
final sessionDirectory =
|
||||
await Directory.systemTemp.createTemp(
|
||||
'youtube_personal_ytdlp_relay_',
|
||||
);
|
||||
|
||||
final server = await HttpServer.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
shared: false,
|
||||
);
|
||||
|
||||
_sessionDirectory = sessionDirectory;
|
||||
_server = server;
|
||||
|
||||
_sources
|
||||
..clear()
|
||||
..['/video'] = _RelaySource(
|
||||
id: 'video',
|
||||
ytDlpPath: ytDlp.path,
|
||||
upstream: videoUri,
|
||||
headers: videoHeaders,
|
||||
length: videoLength,
|
||||
chunkSize: _safeChunkSize(videoChunkSize),
|
||||
contentType: videoMimeType,
|
||||
ext: _extensionForMime(
|
||||
videoMimeType,
|
||||
fallback: 'mp4',
|
||||
),
|
||||
)
|
||||
..['/audio'] = _RelaySource(
|
||||
id: 'audio',
|
||||
ytDlpPath: ytDlp.path,
|
||||
upstream: audioUri,
|
||||
headers: audioHeaders,
|
||||
length: audioLength,
|
||||
chunkSize: _safeChunkSize(audioChunkSize),
|
||||
contentType: audioMimeType,
|
||||
ext: _extensionForMime(
|
||||
audioMimeType,
|
||||
fallback: 'm4a',
|
||||
),
|
||||
);
|
||||
|
||||
_serverSubscription = server.listen(
|
||||
(request) {
|
||||
unawaited(_handleRequest(request));
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
debugPrint(
|
||||
'[HD-RELAY] erreur serveur local: $error',
|
||||
);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
final base =
|
||||
'http://127.0.0.1:${server.port}';
|
||||
|
||||
debugPrint(
|
||||
'[HD-RELAY][17.12] actif sur 127.0.0.1:${server.port} '
|
||||
'(transport upstream = yt-dlp HttpFD)',
|
||||
);
|
||||
|
||||
return WindowsRelaySession(
|
||||
videoUrl: '$base/video',
|
||||
audioUrl: '$base/audio',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final subscription = _serverSubscription;
|
||||
_serverSubscription = null;
|
||||
|
||||
if (subscription != null) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
final server = _server;
|
||||
_server = null;
|
||||
|
||||
if (server != null) {
|
||||
await server.close(force: true);
|
||||
}
|
||||
|
||||
final processes =
|
||||
_activeProcesses.toList(growable: false);
|
||||
|
||||
_activeProcesses.clear();
|
||||
|
||||
for (final process in processes) {
|
||||
process.kill();
|
||||
}
|
||||
|
||||
final sessionDirectory = _sessionDirectory;
|
||||
_sessionDirectory = null;
|
||||
|
||||
if (sessionDirectory != null) {
|
||||
try {
|
||||
if (await sessionDirectory.exists()) {
|
||||
await sessionDirectory.delete(
|
||||
recursive: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Windows peut garder brièvement un handle après l'arrêt de yt-dlp.
|
||||
}
|
||||
}
|
||||
|
||||
_sources.clear();
|
||||
}
|
||||
|
||||
int _safeChunkSize(int requested) {
|
||||
if (requested <= 0) {
|
||||
return 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
return requested.clamp(
|
||||
256 * 1024,
|
||||
10 * 1024 * 1024,
|
||||
);
|
||||
}
|
||||
|
||||
String _extensionForMime(
|
||||
String mime, {
|
||||
required String fallback,
|
||||
}) {
|
||||
final normalized = mime.toLowerCase();
|
||||
|
||||
if (normalized.contains('webm')) {
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
if (normalized.contains('mp4') ||
|
||||
normalized.contains('m4a')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
Future<void> _handleRequest(
|
||||
HttpRequest request,
|
||||
) async {
|
||||
final source = _sources[request.uri.path];
|
||||
|
||||
if (source == null) {
|
||||
request.response.statusCode =
|
||||
HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method != 'GET' &&
|
||||
request.method != 'HEAD') {
|
||||
request.response.statusCode =
|
||||
HttpStatus.methodNotAllowed;
|
||||
request.response.headers.set(
|
||||
'allow',
|
||||
'GET, HEAD',
|
||||
);
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final rangeHeader =
|
||||
request.headers.value(
|
||||
HttpHeaders.rangeHeader,
|
||||
);
|
||||
|
||||
final parsed = _parseRange(
|
||||
rangeHeader,
|
||||
source.length,
|
||||
);
|
||||
|
||||
if (rangeHeader != null && parsed == null) {
|
||||
request.response.statusCode =
|
||||
HttpStatus.requestedRangeNotSatisfiable;
|
||||
request.response.headers.set(
|
||||
'content-range',
|
||||
'bytes */${source.length}',
|
||||
);
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final start = parsed?.$1 ?? 0;
|
||||
final end =
|
||||
parsed?.$2 ?? source.length - 1;
|
||||
|
||||
final response = request.response;
|
||||
|
||||
response.statusCode = parsed == null
|
||||
? HttpStatus.ok
|
||||
: HttpStatus.partialContent;
|
||||
|
||||
response.headers.set(
|
||||
'accept-ranges',
|
||||
'bytes',
|
||||
);
|
||||
|
||||
response.headers.set(
|
||||
HttpHeaders.cacheControlHeader,
|
||||
'no-store',
|
||||
);
|
||||
|
||||
response.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
source.contentType,
|
||||
);
|
||||
|
||||
final responseLength =
|
||||
end - start + 1;
|
||||
|
||||
response.contentLength =
|
||||
responseLength;
|
||||
|
||||
if (parsed != null) {
|
||||
response.headers.set(
|
||||
'content-range',
|
||||
'bytes $start-$end/${source.length}',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[HD-RELAY][17.12] ${request.uri.path} '
|
||||
'${request.method} bytes=$start-$end '
|
||||
'(${responseLength}o)',
|
||||
);
|
||||
|
||||
if (request.method == 'HEAD') {
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _pipeWithYtDlp(
|
||||
source: source,
|
||||
start: start,
|
||||
end: end,
|
||||
downstream: response,
|
||||
);
|
||||
} on Object catch (error) {
|
||||
debugPrint(
|
||||
'[HD-RELAY][17.12] ${request.uri.path} échec: $error',
|
||||
);
|
||||
|
||||
try {
|
||||
await response.close();
|
||||
} catch (_) {
|
||||
// Le lecteur peut avoir fermé la connexion lors d'un seek.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(int, int)? _parseRange(
|
||||
String? value,
|
||||
int totalLength,
|
||||
) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final match = RegExp(
|
||||
r'^bytes=(\d*)-(\d*)$',
|
||||
caseSensitive: false,
|
||||
).firstMatch(value.trim());
|
||||
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final startText = match.group(1) ?? '';
|
||||
final endText = match.group(2) ?? '';
|
||||
|
||||
if (startText.isEmpty) {
|
||||
final suffix = int.tryParse(endText);
|
||||
|
||||
if (suffix == null || suffix <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final start =
|
||||
(totalLength - suffix).clamp(
|
||||
0,
|
||||
totalLength - 1,
|
||||
);
|
||||
|
||||
return (start, totalLength - 1);
|
||||
}
|
||||
|
||||
final start = int.tryParse(startText);
|
||||
|
||||
if (start == null ||
|
||||
start < 0 ||
|
||||
start >= totalLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsedEnd = endText.isEmpty
|
||||
? totalLength - 1
|
||||
: int.tryParse(endText);
|
||||
|
||||
if (parsedEnd == null ||
|
||||
parsedEnd < start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final end = parsedEnd.clamp(
|
||||
start,
|
||||
totalLength - 1,
|
||||
);
|
||||
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
Future<void> _pipeWithYtDlp({
|
||||
required _RelaySource source,
|
||||
required int start,
|
||||
required int end,
|
||||
required HttpResponse downstream,
|
||||
}) async {
|
||||
final sessionDirectory = _sessionDirectory;
|
||||
|
||||
if (sessionDirectory == null) {
|
||||
throw StateError(
|
||||
'Session relais yt-dlp fermée.',
|
||||
);
|
||||
}
|
||||
|
||||
final rangeValue =
|
||||
'bytes=$start-$end';
|
||||
|
||||
final infoHeaders =
|
||||
<String, String>{
|
||||
...source.headers,
|
||||
'Accept-Encoding': 'identity',
|
||||
'Range': rangeValue,
|
||||
};
|
||||
|
||||
final info = <String, dynamic>{
|
||||
'id':
|
||||
'youtube-personal-${source.id}',
|
||||
'title':
|
||||
'YouTube Personal ${source.id}',
|
||||
'ext': source.ext,
|
||||
'protocol': 'https',
|
||||
'url': source.upstream.toString(),
|
||||
'http_headers': infoHeaders,
|
||||
'filesize': source.length,
|
||||
'downloader_options':
|
||||
<String, dynamic>{
|
||||
'http_chunk_size':
|
||||
source.chunkSize,
|
||||
},
|
||||
};
|
||||
|
||||
final stamp =
|
||||
'${DateTime.now().microsecondsSinceEpoch}'
|
||||
'-${requestCounter++}';
|
||||
|
||||
final infoFile = File(
|
||||
'${sessionDirectory.path}'
|
||||
'${Platform.pathSeparator}'
|
||||
'${source.id}-$stamp.info.json',
|
||||
);
|
||||
|
||||
await infoFile.writeAsString(
|
||||
jsonEncode(info),
|
||||
encoding: utf8,
|
||||
flush: true,
|
||||
);
|
||||
|
||||
final process = await Process.start(
|
||||
source.ytDlpPath,
|
||||
<String>[
|
||||
'--ignore-config',
|
||||
'--no-warnings',
|
||||
'--no-progress',
|
||||
'--socket-timeout',
|
||||
'20',
|
||||
'--retries',
|
||||
'2',
|
||||
'--no-part',
|
||||
'--load-info-json',
|
||||
infoFile.path,
|
||||
'-o',
|
||||
'-',
|
||||
],
|
||||
workingDirectory:
|
||||
File(source.ytDlpPath).parent.path,
|
||||
runInShell: false,
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
_activeProcesses.add(process);
|
||||
|
||||
final stderrBuffer =
|
||||
StringBuffer();
|
||||
|
||||
final stderrSubscription =
|
||||
process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen(
|
||||
(text) {
|
||||
if (stderrBuffer.length < 4000) {
|
||||
final remaining =
|
||||
4000 - stderrBuffer.length;
|
||||
|
||||
stderrBuffer.write(
|
||||
text.length <= remaining
|
||||
? text
|
||||
: text.substring(0, remaining),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
var written = 0;
|
||||
Object? streamError;
|
||||
|
||||
debugPrint(
|
||||
'[HD-RELAY][17.12] ${source.id}: '
|
||||
'yt-dlp démarre Range=$rangeValue',
|
||||
);
|
||||
|
||||
try {
|
||||
await for (final data in process.stdout) {
|
||||
if (written >= end - start + 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
final remaining =
|
||||
end - start + 1 - written;
|
||||
|
||||
if (data.length <= remaining) {
|
||||
downstream.add(data);
|
||||
written += data.length;
|
||||
} else {
|
||||
downstream.add(
|
||||
data.sublist(0, remaining),
|
||||
);
|
||||
written += remaining;
|
||||
}
|
||||
|
||||
// Backpressure : permet au lecteur de contrôler la vitesse
|
||||
// au lieu de remplir massivement la mémoire.
|
||||
await downstream.flush();
|
||||
|
||||
if (written >= end - start + 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} on Object catch (error) {
|
||||
streamError = error;
|
||||
}
|
||||
|
||||
if (written >= end - start + 1) {
|
||||
// La plage demandée est complète. Si yt-dlp n'a pas encore quitté
|
||||
// (par ex. sortie stdout plus large), on peut arrêter le process.
|
||||
process.kill();
|
||||
}
|
||||
|
||||
final exitCode = await process.exitCode;
|
||||
|
||||
await stderrSubscription.cancel();
|
||||
_activeProcesses.remove(process);
|
||||
|
||||
try {
|
||||
if (await infoFile.exists()) {
|
||||
await infoFile.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Non bloquant.
|
||||
}
|
||||
|
||||
if (streamError != null) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
if (written != end - start + 1) {
|
||||
final stderr = stderrBuffer
|
||||
.toString()
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
throw HttpException(
|
||||
'yt-dlp a fourni $written/'
|
||||
'${end - start + 1} octets '
|
||||
'(code=$exitCode)'
|
||||
'${stderr.isEmpty ? '' : ': $stderr'}',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[HD-RELAY][17.12] ${source.id}: '
|
||||
'yt-dlp OK ${written}o',
|
||||
);
|
||||
|
||||
await downstream.close();
|
||||
}
|
||||
|
||||
int requestCounter = 0;
|
||||
}
|
||||
|
||||
class _RelaySource {
|
||||
const _RelaySource({
|
||||
required this.id,
|
||||
required this.ytDlpPath,
|
||||
required this.upstream,
|
||||
required this.headers,
|
||||
required this.length,
|
||||
required this.chunkSize,
|
||||
required this.contentType,
|
||||
required this.ext,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String ytDlpPath;
|
||||
final Uri upstream;
|
||||
final Map<String, String> headers;
|
||||
final int length;
|
||||
final int chunkSize;
|
||||
final String contentType;
|
||||
final String ext;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import '../models/video_item.dart';
|
||||
|
||||
class YoungAudienceFilterService {
|
||||
YoungAudienceFilterService._();
|
||||
|
||||
static bool shouldHide(VideoItem video, int level) {
|
||||
if (level <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final score = scoreVideo(video);
|
||||
final threshold = level >= 2 ? 4 : 8;
|
||||
return score >= threshold;
|
||||
}
|
||||
|
||||
static int scoreVideo(VideoItem video) {
|
||||
final author = _normalize(video.author);
|
||||
final title = _normalize(video.title);
|
||||
final description = _normalize(video.description);
|
||||
final combined = ' $author $title $description ';
|
||||
|
||||
var score = 0;
|
||||
|
||||
for (final marker in _knownYoungAudienceBrands) {
|
||||
if (author.contains(marker) || title.contains(marker)) {
|
||||
score += 12;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (final marker in _strongSignals) {
|
||||
if (combined.contains(' $marker ')) {
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
|
||||
for (final marker in _phraseSignals) {
|
||||
if (combined.contains(marker)) {
|
||||
score += 6;
|
||||
}
|
||||
}
|
||||
|
||||
for (final marker in _softSignals) {
|
||||
if (combined.contains(' $marker ')) {
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return score.clamp(0, 30).toInt();
|
||||
}
|
||||
|
||||
static const List<String> _knownYoungAudienceBrands = <String>[
|
||||
'cocomelon',
|
||||
'babybus',
|
||||
'little angel',
|
||||
'kids diana',
|
||||
'vlad and niki',
|
||||
'like nastya',
|
||||
'ryans world',
|
||||
'ryan world',
|
||||
'toys and colors',
|
||||
'chuchu tv',
|
||||
'pinkfong',
|
||||
'super simple songs',
|
||||
'blippi',
|
||||
'peppa pig',
|
||||
'masha and the bear',
|
||||
'vlad a4',
|
||||
'vlad ara',
|
||||
'ninja kidz tv',
|
||||
'kids fun tv',
|
||||
'a for adley',
|
||||
'sis vs bro',
|
||||
'salish matter',
|
||||
];
|
||||
|
||||
static const List<String> _strongSignals = <String>[
|
||||
'kids',
|
||||
'kid',
|
||||
'children',
|
||||
'child',
|
||||
'baby',
|
||||
'babies',
|
||||
'toddler',
|
||||
'toddlers',
|
||||
'preschool',
|
||||
'nursery',
|
||||
'toys',
|
||||
'toy',
|
||||
'enfant',
|
||||
'enfants',
|
||||
'bebe',
|
||||
'bebes',
|
||||
'jouet',
|
||||
'jouets',
|
||||
'comptine',
|
||||
'comptines',
|
||||
'maternelle',
|
||||
'creche',
|
||||
'ninos',
|
||||
'juguetes',
|
||||
'kinder',
|
||||
'spielzeug',
|
||||
'اطفال',
|
||||
'أطفال',
|
||||
'طفل',
|
||||
'رضيع',
|
||||
'العاب',
|
||||
'ألعاب',
|
||||
'اناشيد',
|
||||
'أناشيد',
|
||||
];
|
||||
|
||||
static const List<String> _phraseSignals = <String>[
|
||||
'nursery rhyme',
|
||||
'nursery rhymes',
|
||||
'kids song',
|
||||
'kids songs',
|
||||
'baby song',
|
||||
'baby songs',
|
||||
'toy review',
|
||||
'for kids',
|
||||
'for children',
|
||||
'pour enfants',
|
||||
'pour bebe',
|
||||
'pour les enfants',
|
||||
'dessin anime enfant',
|
||||
'dessins animes enfants',
|
||||
];
|
||||
|
||||
static const List<String> _softSignals = <String>[
|
||||
'school',
|
||||
'cartoon',
|
||||
'animation',
|
||||
'family',
|
||||
'familyfun',
|
||||
'playtime',
|
||||
'playhouse',
|
||||
'slime',
|
||||
'challenge',
|
||||
];
|
||||
|
||||
static String _normalize(String value) {
|
||||
var text = value.toLowerCase();
|
||||
|
||||
const replacements = <String, String>{
|
||||
'à': 'a',
|
||||
'â': 'a',
|
||||
'ä': 'a',
|
||||
'á': 'a',
|
||||
'ã': 'a',
|
||||
'ç': 'c',
|
||||
'é': 'e',
|
||||
'è': 'e',
|
||||
'ê': 'e',
|
||||
'ë': 'e',
|
||||
'î': 'i',
|
||||
'ï': 'i',
|
||||
'í': 'i',
|
||||
'ô': 'o',
|
||||
'ö': 'o',
|
||||
'ó': 'o',
|
||||
'ù': 'u',
|
||||
'û': 'u',
|
||||
'ü': 'u',
|
||||
'ú': 'u',
|
||||
'ÿ': 'y',
|
||||
};
|
||||
|
||||
replacements.forEach((source, target) {
|
||||
text = text.replaceAll(source, target);
|
||||
});
|
||||
|
||||
return text
|
||||
.replaceAll(RegExp(r'[^a-z0-9\u0600-\u06ff]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/channel_details.dart';
|
||||
import '../models/subscription_channel.dart';
|
||||
import '../models/video_item.dart';
|
||||
|
||||
class YoutubeAccountService {
|
||||
Future<List<SubscriptionChannel>> fetchSubscriptions(
|
||||
String accessToken,
|
||||
) async {
|
||||
final result = <SubscriptionChannel>[];
|
||||
String? pageToken;
|
||||
|
||||
do {
|
||||
final decoded = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/subscriptions',
|
||||
{
|
||||
'part': 'snippet',
|
||||
'mine': 'true',
|
||||
'maxResults': '50',
|
||||
'order': 'alphabetical',
|
||||
if (pageToken != null)
|
||||
'pageToken': pageToken,
|
||||
},
|
||||
);
|
||||
|
||||
final items = decoded['items'];
|
||||
|
||||
if (items is List) {
|
||||
for (final item in items) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final snippet = item['snippet'];
|
||||
|
||||
if (snippet is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final resourceId = snippet['resourceId'];
|
||||
final thumbnails = snippet['thumbnails'];
|
||||
|
||||
final channelId = resourceId is Map
|
||||
? resourceId['channelId']
|
||||
: null;
|
||||
|
||||
final title = snippet['title'];
|
||||
|
||||
if (channelId is! String ||
|
||||
title is! String) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.add(
|
||||
SubscriptionChannel(
|
||||
channelId: channelId,
|
||||
title: title,
|
||||
thumbnailUrl:
|
||||
_bestThumbnailUrl(thumbnails),
|
||||
description:
|
||||
snippet['description'] is String
|
||||
? snippet['description'] as String
|
||||
: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final next = decoded['nextPageToken'];
|
||||
|
||||
pageToken =
|
||||
next is String && next.isNotEmpty
|
||||
? next
|
||||
: null;
|
||||
} while (pageToken != null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<VideoItem>> fetchRecentVideosForChannel(
|
||||
String accessToken,
|
||||
String channelId, {
|
||||
int maxResults = 30,
|
||||
}) async {
|
||||
final safeMaxResults = maxResults.clamp(1, 50);
|
||||
|
||||
// 1. Récupère la playlist système "uploads" de la chaîne.
|
||||
final channelData = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/channels',
|
||||
{
|
||||
'part': 'contentDetails',
|
||||
'id': channelId,
|
||||
},
|
||||
);
|
||||
|
||||
final channelItems = channelData['items'];
|
||||
|
||||
if (channelItems is! List ||
|
||||
channelItems.isEmpty ||
|
||||
channelItems.first is! Map) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final channel = channelItems.first as Map;
|
||||
final contentDetails = channel['contentDetails'];
|
||||
|
||||
if (contentDetails is! Map) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final relatedPlaylists =
|
||||
contentDetails['relatedPlaylists'];
|
||||
|
||||
if (relatedPlaylists is! Map) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final uploadsPlaylistId =
|
||||
relatedPlaylists['uploads'];
|
||||
|
||||
if (uploadsPlaylistId is! String ||
|
||||
uploadsPlaylistId.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
// 2. Les éléments de cette playlist sont les mises en ligne,
|
||||
// dans l'ordre de la playlist YouTube.
|
||||
final playlistData = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/playlistItems',
|
||||
{
|
||||
'part': 'snippet,contentDetails',
|
||||
'playlistId': uploadsPlaylistId,
|
||||
'maxResults': '$safeMaxResults',
|
||||
},
|
||||
);
|
||||
|
||||
final playlistItems = playlistData['items'];
|
||||
|
||||
if (playlistItems is! List ||
|
||||
playlistItems.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final rawVideos = <_RawChannelVideo>[];
|
||||
|
||||
for (final item in playlistItems) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final content = item['contentDetails'];
|
||||
final snippet = item['snippet'];
|
||||
|
||||
if (content is! Map || snippet is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final videoId = content['videoId'];
|
||||
final title = snippet['title'];
|
||||
|
||||
if (videoId is! String ||
|
||||
videoId.isEmpty ||
|
||||
title is! String ||
|
||||
title.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Les entrées privées/supprimées peuvent rester dans une
|
||||
// playlist d'uploads. On évite de les présenter comme vidéos.
|
||||
if (title == 'Private video' ||
|
||||
title == 'Deleted video') {
|
||||
continue;
|
||||
}
|
||||
|
||||
rawVideos.add(
|
||||
_RawChannelVideo(
|
||||
id: videoId,
|
||||
title: title,
|
||||
author:
|
||||
snippet['videoOwnerChannelTitle']
|
||||
is String
|
||||
? snippet[
|
||||
'videoOwnerChannelTitle']
|
||||
as String
|
||||
: snippet['channelTitle']
|
||||
is String
|
||||
? snippet['channelTitle']
|
||||
as String
|
||||
: '',
|
||||
thumbnailUrl: _bestThumbnailUrl(
|
||||
snippet['thumbnails'],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (rawVideos.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
// 3. playlistItems ne donne pas la durée. videos.list permet
|
||||
// de récupérer contentDetails.duration pour tous les IDs en
|
||||
// une seule requête (jusqu'à 50 IDs).
|
||||
final videoData = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/videos',
|
||||
{
|
||||
'part': 'contentDetails,status',
|
||||
'id': rawVideos
|
||||
.map((video) => video.id)
|
||||
.join(','),
|
||||
},
|
||||
);
|
||||
|
||||
final durationById = <String, Duration?>{};
|
||||
final availableIds = <String>{};
|
||||
|
||||
final videoItems = videoData['items'];
|
||||
|
||||
if (videoItems is List) {
|
||||
for (final item in videoItems) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = item['id'];
|
||||
|
||||
if (id is! String || id.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final status = item['status'];
|
||||
|
||||
if (status is Map &&
|
||||
status['privacyStatus'] == 'private') {
|
||||
continue;
|
||||
}
|
||||
|
||||
availableIds.add(id);
|
||||
|
||||
final details = item['contentDetails'];
|
||||
final durationText =
|
||||
details is Map
|
||||
? details['duration']
|
||||
: null;
|
||||
|
||||
durationById[id] =
|
||||
durationText is String
|
||||
? _parseIso8601Duration(
|
||||
durationText,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
return rawVideos
|
||||
.where(
|
||||
(video) => availableIds.contains(video.id),
|
||||
)
|
||||
.map(
|
||||
(video) => VideoItem(
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
author: video.author,
|
||||
thumbnailUrl: video.thumbnailUrl,
|
||||
duration: durationById[video.id],
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<ChannelDetails?> fetchChannelDetails(
|
||||
String accessToken,
|
||||
String channelId,
|
||||
) async {
|
||||
final decoded = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/channels',
|
||||
<String, String>{
|
||||
'part': 'snippet,statistics',
|
||||
'id': channelId,
|
||||
},
|
||||
);
|
||||
|
||||
final items = decoded['items'];
|
||||
|
||||
if (items is! List ||
|
||||
items.isEmpty ||
|
||||
items.first is! Map) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final item = items.first as Map;
|
||||
final snippet = item['snippet'];
|
||||
final statistics = item['statistics'];
|
||||
|
||||
if (snippet is! Map) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final hiddenSubscriberCount =
|
||||
statistics is Map &&
|
||||
statistics['hiddenSubscriberCount'] == true;
|
||||
|
||||
return ChannelDetails(
|
||||
channelId: channelId,
|
||||
title: snippet['title'] is String
|
||||
? snippet['title'] as String
|
||||
: '',
|
||||
description: snippet['description'] is String
|
||||
? snippet['description'] as String
|
||||
: '',
|
||||
thumbnailUrl:
|
||||
_bestThumbnailUrl(snippet['thumbnails']),
|
||||
publishedAt: snippet['publishedAt'] is String
|
||||
? DateTime.tryParse(
|
||||
snippet['publishedAt'] as String,
|
||||
)
|
||||
: null,
|
||||
viewCount: statistics is Map
|
||||
? _parseInt(statistics['viewCount'])
|
||||
: null,
|
||||
subscriberCount:
|
||||
hiddenSubscriberCount || statistics is! Map
|
||||
? null
|
||||
: _parseInt(
|
||||
statistics['subscriberCount'],
|
||||
),
|
||||
hiddenSubscriberCount:
|
||||
hiddenSubscriberCount,
|
||||
videoCount: statistics is Map
|
||||
? _parseInt(statistics['videoCount'])
|
||||
: null,
|
||||
country: snippet['country'] is String
|
||||
? snippet['country'] as String
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Streams all public uploads from a channel through YouTube Data API v3.
|
||||
///
|
||||
/// Unlike the old 30-video helper, this follows playlistItems.nextPageToken
|
||||
/// until the uploads playlist is exhausted and enriches each page with
|
||||
/// duration, public view count and publication date.
|
||||
Stream<VideoItem> streamAllVideosForChannel(
|
||||
String accessToken,
|
||||
String channelId,
|
||||
) async* {
|
||||
final channelData = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/channels',
|
||||
<String, String>{
|
||||
'part': 'contentDetails',
|
||||
'id': channelId,
|
||||
},
|
||||
);
|
||||
|
||||
final channelItems = channelData['items'];
|
||||
if (channelItems is! List ||
|
||||
channelItems.isEmpty ||
|
||||
channelItems.first is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
final contentDetails =
|
||||
(channelItems.first as Map)['contentDetails'];
|
||||
if (contentDetails is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
final relatedPlaylists = contentDetails['relatedPlaylists'];
|
||||
if (relatedPlaylists is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
final uploadsPlaylistId = relatedPlaylists['uploads'];
|
||||
if (uploadsPlaylistId is! String || uploadsPlaylistId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
String? pageToken;
|
||||
|
||||
do {
|
||||
final playlistData = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/playlistItems',
|
||||
<String, String>{
|
||||
'part': 'snippet,contentDetails',
|
||||
'playlistId': uploadsPlaylistId,
|
||||
'maxResults': '50',
|
||||
if (pageToken != null) 'pageToken': pageToken,
|
||||
},
|
||||
);
|
||||
|
||||
final playlistItems = playlistData['items'];
|
||||
final rawVideos = <_RawFullChannelVideo>[];
|
||||
|
||||
if (playlistItems is List) {
|
||||
for (final item in playlistItems) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final content = item['contentDetails'];
|
||||
final snippet = item['snippet'];
|
||||
|
||||
if (content is! Map || snippet is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final videoId = content['videoId'];
|
||||
final title = snippet['title'];
|
||||
|
||||
if (videoId is! String ||
|
||||
videoId.isEmpty ||
|
||||
title is! String ||
|
||||
title.isEmpty ||
|
||||
title == 'Private video' ||
|
||||
title == 'Deleted video') {
|
||||
continue;
|
||||
}
|
||||
|
||||
rawVideos.add(
|
||||
_RawFullChannelVideo(
|
||||
id: videoId,
|
||||
title: title,
|
||||
author: snippet['videoOwnerChannelTitle'] is String
|
||||
? snippet['videoOwnerChannelTitle'] as String
|
||||
: snippet['channelTitle'] is String
|
||||
? snippet['channelTitle'] as String
|
||||
: '',
|
||||
thumbnailUrl: _bestThumbnailUrl(snippet['thumbnails']),
|
||||
description: snippet['description'] is String
|
||||
? snippet['description'] as String
|
||||
: '',
|
||||
uploadDate: snippet['publishedAt'] is String
|
||||
? DateTime.tryParse(snippet['publishedAt'] as String)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawVideos.isNotEmpty) {
|
||||
final videoData = await _authorizedGetJson(
|
||||
accessToken,
|
||||
'/youtube/v3/videos',
|
||||
<String, String>{
|
||||
'part': 'contentDetails,status,statistics,snippet',
|
||||
'id': rawVideos.map((video) => video.id).join(','),
|
||||
'maxResults': '50',
|
||||
},
|
||||
);
|
||||
|
||||
final byId = <String, Map>{};
|
||||
final videoItems = videoData['items'];
|
||||
|
||||
if (videoItems is List) {
|
||||
for (final item in videoItems) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = item['id'];
|
||||
if (id is String && id.isNotEmpty) {
|
||||
byId[id] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final raw in rawVideos) {
|
||||
final item = byId[raw.id];
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final status = item['status'];
|
||||
if (status is Map && status['privacyStatus'] == 'private') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final details = item['contentDetails'];
|
||||
final durationText =
|
||||
details is Map ? details['duration'] : null;
|
||||
|
||||
final statistics = item['statistics'];
|
||||
final viewCountText =
|
||||
statistics is Map ? statistics['viewCount'] : null;
|
||||
final likeCountText =
|
||||
statistics is Map ? statistics['likeCount'] : null;
|
||||
final commentCountText =
|
||||
statistics is Map ? statistics['commentCount'] : null;
|
||||
|
||||
final apiSnippet = item['snippet'];
|
||||
final apiPublishedAt = apiSnippet is Map
|
||||
? apiSnippet['publishedAt']
|
||||
: null;
|
||||
|
||||
yield VideoItem(
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
author: raw.author,
|
||||
channelId: channelId,
|
||||
thumbnailUrl: raw.thumbnailUrl,
|
||||
description: raw.description,
|
||||
duration: durationText is String
|
||||
? _parseIso8601Duration(durationText)
|
||||
: null,
|
||||
viewCount: _parseInt(viewCountText),
|
||||
likeCount: _parseInt(likeCountText),
|
||||
commentCount: _parseInt(commentCountText),
|
||||
uploadDate: apiPublishedAt is String
|
||||
? DateTime.tryParse(apiPublishedAt)
|
||||
: raw.uploadDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final next = playlistData['nextPageToken'];
|
||||
pageToken = next is String && next.isNotEmpty ? next : null;
|
||||
} while (pageToken != null);
|
||||
}
|
||||
|
||||
int? _parseInt(dynamic value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
if (value is String) {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<dynamic, dynamic>> _authorizedGetJson(
|
||||
String accessToken,
|
||||
String path,
|
||||
Map<String, String> query,
|
||||
) async {
|
||||
final uri = Uri.https(
|
||||
'www.googleapis.com',
|
||||
path,
|
||||
query,
|
||||
);
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
throw StateError(
|
||||
'La session Google a expiré. '
|
||||
'Déconnecte puis reconnecte ton compte.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode < 200 ||
|
||||
response.statusCode >= 300) {
|
||||
final apiError = _parseApiError(
|
||||
response.statusCode,
|
||||
response.body,
|
||||
);
|
||||
|
||||
print(
|
||||
'[YT-ACCOUNT][API-ERROR] '
|
||||
'status=${response.statusCode} '
|
||||
'reason=${apiError.reason} '
|
||||
'message=${apiError.message}',
|
||||
);
|
||||
|
||||
throw StateError(
|
||||
_friendlyErrorMessage(
|
||||
response.statusCode,
|
||||
apiError,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
||||
if (decoded is! Map) {
|
||||
throw StateError(
|
||||
'Réponse YouTube Data API invalide.',
|
||||
);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
String _bestThumbnailUrl(dynamic thumbnails) {
|
||||
if (thumbnails is! Map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (final key in const [
|
||||
'maxres',
|
||||
'standard',
|
||||
'high',
|
||||
'medium',
|
||||
'default',
|
||||
]) {
|
||||
final value = thumbnails[key];
|
||||
|
||||
if (value is Map &&
|
||||
value['url'] is String) {
|
||||
return value['url'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
Duration? _parseIso8601Duration(
|
||||
String value,
|
||||
) {
|
||||
final match = RegExp(
|
||||
r'^P(?:(\d+)D)?'
|
||||
r'(?:T'
|
||||
r'(?:(\d+)H)?'
|
||||
r'(?:(\d+)M)?'
|
||||
r'(?:(\d+(?:\.\d+)?)S)?'
|
||||
r')?$',
|
||||
).firstMatch(value);
|
||||
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final days =
|
||||
int.tryParse(match.group(1) ?? '') ?? 0;
|
||||
final hours =
|
||||
int.tryParse(match.group(2) ?? '') ?? 0;
|
||||
final minutes =
|
||||
int.tryParse(match.group(3) ?? '') ?? 0;
|
||||
final seconds =
|
||||
double.tryParse(match.group(4) ?? '') ?? 0;
|
||||
|
||||
final totalMilliseconds =
|
||||
(((days * 24 + hours) * 60 + minutes) *
|
||||
60 *
|
||||
1000 +
|
||||
seconds * 1000)
|
||||
.round();
|
||||
|
||||
return Duration(
|
||||
milliseconds: totalMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
_YoutubeApiError _parseApiError(
|
||||
int statusCode,
|
||||
String body,
|
||||
) {
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
|
||||
if (decoded is Map) {
|
||||
final error = decoded['error'];
|
||||
|
||||
if (error is Map) {
|
||||
final topMessage =
|
||||
error['message'] is String
|
||||
? error['message'] as String
|
||||
: '';
|
||||
|
||||
final errors = error['errors'];
|
||||
|
||||
if (errors is List &&
|
||||
errors.isNotEmpty) {
|
||||
final first = errors.first;
|
||||
|
||||
if (first is Map) {
|
||||
return _YoutubeApiError(
|
||||
reason:
|
||||
first['reason'] is String
|
||||
? first['reason'] as String
|
||||
: 'unknown',
|
||||
message:
|
||||
first['message'] is String
|
||||
? first['message'] as String
|
||||
: topMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final details = error['details'];
|
||||
|
||||
if (details is List) {
|
||||
for (final detail in details) {
|
||||
if (detail is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final reason = detail['reason'];
|
||||
|
||||
if (reason is String &&
|
||||
reason.isNotEmpty) {
|
||||
return _YoutubeApiError(
|
||||
reason: reason,
|
||||
message: topMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _YoutubeApiError(
|
||||
reason: 'unknown',
|
||||
message: topMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Le corps brut sera repris ci-dessous.
|
||||
}
|
||||
|
||||
return _YoutubeApiError(
|
||||
reason: 'unknown',
|
||||
message: body,
|
||||
);
|
||||
}
|
||||
|
||||
String _friendlyErrorMessage(
|
||||
int statusCode,
|
||||
_YoutubeApiError error,
|
||||
) {
|
||||
final reason = error.reason;
|
||||
|
||||
if (statusCode == 403) {
|
||||
switch (reason) {
|
||||
case 'insufficientPermissions':
|
||||
return 'Google a accepté la connexion, '
|
||||
'mais le jeton OAuth ne contient pas '
|
||||
'la permission YouTube nécessaire '
|
||||
'(youtube.readonly). Ajoute ce scope '
|
||||
'dans Google Auth Platform > Accès aux '
|
||||
'données, puis déconnecte et reconnecte '
|
||||
'le compte dans l’application.';
|
||||
|
||||
case 'accessNotConfigured':
|
||||
case 'serviceDisabled':
|
||||
case 'apiDisabled':
|
||||
return 'YouTube Data API v3 est désactivée '
|
||||
'pour ce projet Google Cloud.';
|
||||
|
||||
case 'quotaExceeded':
|
||||
case 'dailyLimitExceeded':
|
||||
return 'Le quota YouTube Data API du projet '
|
||||
'est temporairement épuisé.';
|
||||
|
||||
case 'accountClosed':
|
||||
return 'Le compte ou la chaîne YouTube '
|
||||
'associée est fermé(e).';
|
||||
|
||||
case 'accountSuspended':
|
||||
return 'YouTube indique que la chaîne '
|
||||
'associée au compte est suspendue.';
|
||||
|
||||
case 'subscriptionForbidden':
|
||||
return 'Google a authentifié le compte, '
|
||||
'mais YouTube refuse l’accès aux '
|
||||
'abonnements pour ce compte.';
|
||||
|
||||
default:
|
||||
return 'YouTube Data API refuse la requête '
|
||||
'(403, raison: $reason). '
|
||||
'${error.message}';
|
||||
}
|
||||
}
|
||||
|
||||
return 'YouTube Data API $statusCode '
|
||||
'(raison: $reason): ${error.message}';
|
||||
}
|
||||
}
|
||||
|
||||
class _RawChannelVideo {
|
||||
const _RawChannelVideo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.thumbnailUrl,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String author;
|
||||
final String thumbnailUrl;
|
||||
}
|
||||
|
||||
class _RawFullChannelVideo {
|
||||
const _RawFullChannelVideo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.thumbnailUrl,
|
||||
required this.description,
|
||||
required this.uploadDate,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String author;
|
||||
final String thumbnailUrl;
|
||||
final String description;
|
||||
final DateTime? uploadDate;
|
||||
}
|
||||
|
||||
class _YoutubeApiError {
|
||||
const _YoutubeApiError({
|
||||
required this.reason,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final String reason;
|
||||
final String message;
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
import '../models/channel_details.dart';
|
||||
import '../models/subscription_channel.dart';
|
||||
import '../models/video_item.dart';
|
||||
import 'windows_channel_metadata_service.dart';
|
||||
|
||||
enum ChannelUploadSort {
|
||||
newest,
|
||||
popularity,
|
||||
oldest,
|
||||
}
|
||||
|
||||
class ChannelUploadBatch {
|
||||
const ChannelUploadBatch({
|
||||
required this.videos,
|
||||
required this.hasMore,
|
||||
});
|
||||
|
||||
final List<VideoItem> videos;
|
||||
final bool hasMore;
|
||||
}
|
||||
|
||||
class YoutubeService {
|
||||
final YoutubeExplode _youtube = YoutubeExplode();
|
||||
|
||||
ChannelUploadsList? _channelUploadsPage;
|
||||
String? _channelUploadsChannelId;
|
||||
ChannelUploadSort? _channelUploadsSort;
|
||||
bool _channelUploadsExhausted = false;
|
||||
|
||||
Future<List<VideoItem>> searchVideos(String query) async {
|
||||
final normalizedQuery = query.trim();
|
||||
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
try {
|
||||
final results = await _youtube.search.search(normalizedQuery);
|
||||
|
||||
return results
|
||||
.map(_toVideoItem)
|
||||
.toList(growable: false);
|
||||
} catch (error) {
|
||||
return _searchWithYtDlp(normalizedQuery);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<VideoItem>> _searchWithYtDlp(String query) async {
|
||||
try {
|
||||
final result = await Process.run('yt-dlp', [
|
||||
'--flat-playlist',
|
||||
'-j',
|
||||
'ytsearch10:$query',
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final lines = (result.stdout as String)
|
||||
.split('\n')
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final videos = <VideoItem>[];
|
||||
for (final line in lines) {
|
||||
try {
|
||||
final json = jsonDecode(line) as Map<String, dynamic>;
|
||||
videos.add(VideoItem(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
author: json['channel'] as String? ?? json['uploader'] as String? ?? '',
|
||||
channelId: json['channel_id'] as String? ?? '',
|
||||
duration: json['duration'] != null
|
||||
? Duration(seconds: (json['duration'] as num).toInt())
|
||||
: null,
|
||||
viewCount: json['view_count'] as num?,
|
||||
likeCount: null,
|
||||
description: json['description'] as String? ?? '',
|
||||
uploadDate: null,
|
||||
thumbnailUrl: json['thumbnails'] != null
|
||||
? (json['thumbnails'] as List).isNotEmpty
|
||||
? (json['thumbnails'][0] as Map<String, dynamic>)['url'] as String? ??
|
||||
'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg'
|
||||
: 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg'
|
||||
: 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg',
|
||||
));
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return videos;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<VideoItem> getVideoDetails(
|
||||
String videoId,
|
||||
) async {
|
||||
final video =
|
||||
await _youtube.videos.get(videoId);
|
||||
|
||||
return _toVideoItem(video);
|
||||
}
|
||||
|
||||
/// Resolves the uploader directly from the video.
|
||||
///
|
||||
/// This is intentionally independent from Google OAuth, so clicking the
|
||||
/// channel works for search results, Radar recommendations, history and
|
||||
/// favorites on both Windows and Android.
|
||||
Future<SubscriptionChannel> getChannelForVideo(
|
||||
String videoId,
|
||||
) async {
|
||||
final channel =
|
||||
await _youtube.channels.getByVideo(videoId);
|
||||
|
||||
return _toSubscriptionChannel(channel);
|
||||
}
|
||||
|
||||
Future<SubscriptionChannel> getChannel(
|
||||
String channelId,
|
||||
) async {
|
||||
final channel =
|
||||
await _youtube.channels.get(channelId);
|
||||
|
||||
return _toSubscriptionChannel(channel);
|
||||
}
|
||||
|
||||
Future<ChannelDetails?> getChannelPreviewDetails(
|
||||
String channelId,
|
||||
) async {
|
||||
if (channelId.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final channel =
|
||||
await _youtube.channels.get(channelId);
|
||||
|
||||
return ChannelDetails(
|
||||
channelId: channel.id.value,
|
||||
title: channel.title,
|
||||
description: '',
|
||||
thumbnailUrl: channel.logoUrl,
|
||||
subscriberCount: channel.subscribersCount,
|
||||
hiddenSubscriberCount:
|
||||
channel.subscribersCount == null,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Public channel metadata that does NOT require Google OAuth.
|
||||
///
|
||||
/// youtube_explode_dart exposes the channel's approximate subscriber
|
||||
/// count and the public About page exposes description, channel views,
|
||||
/// join date and country.
|
||||
Future<ChannelDetails> getPublicChannelDetails(
|
||||
String channelId,
|
||||
) async {
|
||||
Channel? channel;
|
||||
ChannelAbout? about;
|
||||
|
||||
try {
|
||||
channel = await _youtube.channels.get(channelId);
|
||||
} catch (error) {
|
||||
// The Windows yt-dlp fallback below can still recover channel metadata.
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[CHANNEL-PUBLIC][18.5.2] Channel.get échoué: $error',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
about = await _youtube.channels.getAboutPage(channelId);
|
||||
} catch (error) {
|
||||
// Current YouTube layouts sometimes make the public About parser return
|
||||
// nothing. This is expected; yt-dlp is our Windows fallback.
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[CHANNEL-PUBLIC][18.5.2] About indisponible: $error',
|
||||
);
|
||||
}
|
||||
|
||||
final publicDetails = ChannelDetails(
|
||||
channelId: channel?.id.value ?? channelId,
|
||||
title: channel?.title ?? about?.title ?? '',
|
||||
description: about?.description ?? '',
|
||||
thumbnailUrl: channel?.logoUrl ??
|
||||
(about != null && about.thumbnails.isNotEmpty
|
||||
? about.thumbnails.last.url.toString()
|
||||
: ''),
|
||||
joinDateLabel: about?.joinDate,
|
||||
viewCount: about?.viewCount,
|
||||
subscriberCount: channel?.subscribersCount,
|
||||
hiddenSubscriberCount:
|
||||
channel?.subscribersCount == null,
|
||||
country: about?.country,
|
||||
);
|
||||
|
||||
final windowsDetails =
|
||||
await WindowsChannelMetadataService.instance
|
||||
.getDetails(channelId);
|
||||
|
||||
final merged = _mergeChannelDetails(
|
||||
publicDetails,
|
||||
windowsDetails,
|
||||
);
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[CHANNEL-META][18.5.2] id=$channelId '
|
||||
'subs=${merged.subscriberCount ?? 'NA'} '
|
||||
'views=${merged.viewCount ?? 'NA'} '
|
||||
'videos=${merged.videoCount ?? 'NA'} '
|
||||
'date=${merged.publishedAt?.toIso8601String() ?? merged.joinDateLabel ?? 'NA'}',
|
||||
);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
ChannelDetails _mergeChannelDetails(
|
||||
ChannelDetails primary,
|
||||
ChannelDetails? fallback,
|
||||
) {
|
||||
if (fallback == null) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
String chooseString(
|
||||
String first,
|
||||
String second,
|
||||
) {
|
||||
return first.trim().isNotEmpty
|
||||
? first
|
||||
: second;
|
||||
}
|
||||
|
||||
return ChannelDetails(
|
||||
channelId: chooseString(
|
||||
primary.channelId,
|
||||
fallback.channelId,
|
||||
),
|
||||
title: chooseString(
|
||||
primary.title,
|
||||
fallback.title,
|
||||
),
|
||||
description: chooseString(
|
||||
primary.description,
|
||||
fallback.description,
|
||||
),
|
||||
thumbnailUrl: chooseString(
|
||||
primary.thumbnailUrl,
|
||||
fallback.thumbnailUrl,
|
||||
),
|
||||
publishedAt:
|
||||
primary.publishedAt ?? fallback.publishedAt,
|
||||
joinDateLabel:
|
||||
primary.joinDateLabel ?? fallback.joinDateLabel,
|
||||
viewCount:
|
||||
primary.viewCount ?? fallback.viewCount,
|
||||
subscriberCount:
|
||||
primary.subscriberCount ?? fallback.subscriberCount,
|
||||
hiddenSubscriberCount:
|
||||
primary.subscriberCount == null &&
|
||||
fallback.subscriberCount == null,
|
||||
videoCount:
|
||||
primary.videoCount ?? fallback.videoCount,
|
||||
country:
|
||||
primary.country ?? fallback.country,
|
||||
);
|
||||
}
|
||||
|
||||
VideoSorting _videoSortingFor(ChannelUploadSort sort) {
|
||||
return switch (sort) {
|
||||
ChannelUploadSort.newest => VideoSorting.newest,
|
||||
ChannelUploadSort.popularity => VideoSorting.popularity,
|
||||
ChannelUploadSort.oldest => VideoSorting.oldest,
|
||||
};
|
||||
}
|
||||
|
||||
/// Loads ONE public channel page at a time.
|
||||
///
|
||||
/// youtube_explode_dart exposes ChannelUploadsList.nextPage(), so the UI
|
||||
/// can keep the first ~30 entries fast and explicitly request more instead
|
||||
/// of pretending that the first page is the whole channel.
|
||||
Future<ChannelUploadBatch> getChannelUploadsPage(
|
||||
String channelId, {
|
||||
ChannelUploadSort sort = ChannelUploadSort.newest,
|
||||
bool reset = false,
|
||||
}) async {
|
||||
final normalizedId = channelId.trim();
|
||||
|
||||
if (normalizedId.isEmpty) {
|
||||
return const ChannelUploadBatch(
|
||||
videos: <VideoItem>[],
|
||||
hasMore: false,
|
||||
);
|
||||
}
|
||||
|
||||
final mustReset =
|
||||
reset ||
|
||||
_channelUploadsPage == null ||
|
||||
_channelUploadsChannelId != normalizedId ||
|
||||
_channelUploadsSort != sort;
|
||||
|
||||
ChannelUploadsList? page;
|
||||
|
||||
if (mustReset) {
|
||||
_channelUploadsPage =
|
||||
await _youtube.channels.getUploadsFromPage(
|
||||
normalizedId,
|
||||
videoSorting: _videoSortingFor(sort),
|
||||
);
|
||||
_channelUploadsChannelId = normalizedId;
|
||||
_channelUploadsSort = sort;
|
||||
_channelUploadsExhausted = false;
|
||||
page = _channelUploadsPage;
|
||||
} else {
|
||||
if (_channelUploadsExhausted) {
|
||||
return const ChannelUploadBatch(
|
||||
videos: <VideoItem>[],
|
||||
hasMore: false,
|
||||
);
|
||||
}
|
||||
|
||||
final current = _channelUploadsPage;
|
||||
if (current == null) {
|
||||
return const ChannelUploadBatch(
|
||||
videos: <VideoItem>[],
|
||||
hasMore: false,
|
||||
);
|
||||
}
|
||||
|
||||
page = await current.nextPage();
|
||||
if (page == null) {
|
||||
_channelUploadsExhausted = true;
|
||||
return const ChannelUploadBatch(
|
||||
videos: <VideoItem>[],
|
||||
hasMore: false,
|
||||
);
|
||||
}
|
||||
|
||||
_channelUploadsPage = page;
|
||||
}
|
||||
|
||||
final resolvedPage = page;
|
||||
if (resolvedPage == null) {
|
||||
_channelUploadsExhausted = true;
|
||||
return const ChannelUploadBatch(
|
||||
videos: <VideoItem>[],
|
||||
hasMore: false,
|
||||
);
|
||||
}
|
||||
|
||||
final videos = resolvedPage
|
||||
.map(_toVideoItem)
|
||||
.toList(growable: false);
|
||||
|
||||
// YouTube channel pages are normally around 30 items. There is no public
|
||||
// `hasNextPage` flag on ChannelUploadsList, only nextPage(). A short page
|
||||
// is therefore considered final; an exact full page keeps the button.
|
||||
// If the last page contains exactly 30 entries, the next click simply
|
||||
// receives null and hides the button without losing any data.
|
||||
final hasMore = videos.length >= 30;
|
||||
if (!hasMore) {
|
||||
_channelUploadsExhausted = true;
|
||||
}
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[CHANNEL-VIDEOS][18.7.20] '
|
||||
'channel=$normalizedId '
|
||||
'sort=${sort.name} '
|
||||
'pageVideos=${videos.length} '
|
||||
'hasMore=$hasMore',
|
||||
);
|
||||
|
||||
return ChannelUploadBatch(
|
||||
videos: videos,
|
||||
hasMore: hasMore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Streams the full public upload catalogue when a caller really needs it.
|
||||
/// The channel UI uses [getChannelUploadsPage] instead for progressive load.
|
||||
Stream<VideoItem> getChannelUploads(
|
||||
String channelId, {
|
||||
ChannelUploadSort sort = ChannelUploadSort.newest,
|
||||
}) async* {
|
||||
var page = await _youtube.channels.getUploadsFromPage(
|
||||
channelId,
|
||||
videoSorting: _videoSortingFor(sort),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
for (final video in page) {
|
||||
yield _toVideoItem(video);
|
||||
}
|
||||
|
||||
final next = await page.nextPage();
|
||||
if (next == null) {
|
||||
break;
|
||||
}
|
||||
page = next;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getPlayableMuxedUrl(
|
||||
String videoId,
|
||||
) async {
|
||||
// Stable progressive fallback used by the existing player.
|
||||
final manifest =
|
||||
await _youtube.videos.streams.getManifest(videoId);
|
||||
|
||||
final muxed =
|
||||
manifest.muxed.toList(growable: false);
|
||||
|
||||
if (muxed.isEmpty) {
|
||||
throw StateError(
|
||||
'Aucun flux audio + vidéo compatible n’est disponible.',
|
||||
);
|
||||
}
|
||||
|
||||
final sorted = [...muxed]
|
||||
..sort((a, b) {
|
||||
final height =
|
||||
b.videoResolution.height.compareTo(
|
||||
a.videoResolution.height,
|
||||
);
|
||||
|
||||
if (height != 0) {
|
||||
return height;
|
||||
}
|
||||
|
||||
return b.bitrate.bitsPerSecond.compareTo(
|
||||
a.bitrate.bitsPerSecond,
|
||||
);
|
||||
});
|
||||
|
||||
return sorted.first.url.toString();
|
||||
}
|
||||
|
||||
VideoItem _toVideoItem(Video video) {
|
||||
return VideoItem(
|
||||
id: video.id.value,
|
||||
title: video.title,
|
||||
author: video.author,
|
||||
channelId: video.channelId.value,
|
||||
duration: video.duration,
|
||||
viewCount: video.engagement.viewCount,
|
||||
likeCount: video.engagement.likeCount,
|
||||
description: video.description,
|
||||
uploadDate: video.uploadDate,
|
||||
thumbnailUrl:
|
||||
'https://i.ytimg.com/vi/${video.id.value}/hqdefault.jpg',
|
||||
);
|
||||
}
|
||||
|
||||
SubscriptionChannel _toSubscriptionChannel(
|
||||
Channel channel,
|
||||
) {
|
||||
return SubscriptionChannel(
|
||||
channelId: channel.id.value,
|
||||
title: channel.title,
|
||||
thumbnailUrl: channel.logoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
void close() {
|
||||
_youtube.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user