831 lines
21 KiB
Dart
831 lines
21 KiB
Dart
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;
|
||
}
|