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> fetchSubscriptions( String accessToken, ) async { final result = []; 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> 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 = {}; final availableIds = {}; 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 fetchChannelDetails( String accessToken, String channelId, ) async { final decoded = await _authorizedGetJson( accessToken, '/youtube/v3/channels', { '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 streamAllVideosForChannel( String accessToken, String channelId, ) async* { 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; } 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', { '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', { 'part': 'contentDetails,status,statistics,snippet', 'id': rawVideos.map((video) => video.id).join(','), 'maxResults': '50', }, ); final byId = {}; 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> _authorizedGetJson( String accessToken, String path, Map 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; }