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