Files
youtube_personal/lib/services/youtube_account_service.dart
T

831 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}