Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+830
View File
@@ -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;
}