import 'dart:convert'; import 'dart:io'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import '../models/channel_details.dart'; import '../models/subscription_channel.dart'; import '../models/video_item.dart'; import 'windows_channel_metadata_service.dart'; enum ChannelUploadSort { newest, popularity, oldest, } class ChannelUploadBatch { const ChannelUploadBatch({ required this.videos, required this.hasMore, }); final List videos; final bool hasMore; } class YoutubeService { final YoutubeExplode _youtube = YoutubeExplode(); ChannelUploadsList? _channelUploadsPage; String? _channelUploadsChannelId; ChannelUploadSort? _channelUploadsSort; bool _channelUploadsExhausted = false; Future> searchVideos(String query) async { final normalizedQuery = query.trim(); if (normalizedQuery.isEmpty) { return const []; } try { final results = await _youtube.search.search(normalizedQuery); return results .map(_toVideoItem) .toList(growable: false); } catch (error) { return _searchWithYtDlp(normalizedQuery); } } Future> _searchWithYtDlp(String query) async { try { final result = await Process.run('yt-dlp', [ '--flat-playlist', '-j', 'ytsearch10:$query', ]); if (result.exitCode != 0) { return const []; } final lines = (result.stdout as String) .split('\n') .where((l) => l.trim().isNotEmpty) .toList(); final videos = []; for (final line in lines) { try { final json = jsonDecode(line) as Map; videos.add(VideoItem( id: json['id'] as String? ?? '', title: json['title'] as String? ?? '', author: json['channel'] as String? ?? json['uploader'] as String? ?? '', channelId: json['channel_id'] as String? ?? '', duration: json['duration'] != null ? Duration(seconds: (json['duration'] as num).toInt()) : null, viewCount: json['view_count'] as num?, likeCount: null, description: json['description'] as String? ?? '', uploadDate: null, thumbnailUrl: json['thumbnails'] != null ? (json['thumbnails'] as List).isNotEmpty ? (json['thumbnails'][0] as Map)['url'] as String? ?? 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg' : 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg' : 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg', )); } catch (_) { continue; } } return videos; } catch (_) { return const []; } } Future getVideoDetails( String videoId, ) async { final video = await _youtube.videos.get(videoId); return _toVideoItem(video); } /// Resolves the uploader directly from the video. /// /// This is intentionally independent from Google OAuth, so clicking the /// channel works for search results, Radar recommendations, history and /// favorites on both Windows and Android. Future getChannelForVideo( String videoId, ) async { final channel = await _youtube.channels.getByVideo(videoId); return _toSubscriptionChannel(channel); } Future getChannel( String channelId, ) async { final channel = await _youtube.channels.get(channelId); return _toSubscriptionChannel(channel); } Future getChannelPreviewDetails( String channelId, ) async { if (channelId.trim().isEmpty) { return null; } try { final channel = await _youtube.channels.get(channelId); return ChannelDetails( channelId: channel.id.value, title: channel.title, description: '', thumbnailUrl: channel.logoUrl, subscriberCount: channel.subscribersCount, hiddenSubscriberCount: channel.subscribersCount == null, ); } catch (_) { return null; } } /// Public channel metadata that does NOT require Google OAuth. /// /// youtube_explode_dart exposes the channel's approximate subscriber /// count and the public About page exposes description, channel views, /// join date and country. Future getPublicChannelDetails( String channelId, ) async { Channel? channel; ChannelAbout? about; try { channel = await _youtube.channels.get(channelId); } catch (error) { // The Windows yt-dlp fallback below can still recover channel metadata. // ignore: avoid_print print( '[CHANNEL-PUBLIC][18.5.2] Channel.get échoué: $error', ); } try { about = await _youtube.channels.getAboutPage(channelId); } catch (error) { // Current YouTube layouts sometimes make the public About parser return // nothing. This is expected; yt-dlp is our Windows fallback. // ignore: avoid_print print( '[CHANNEL-PUBLIC][18.5.2] About indisponible: $error', ); } final publicDetails = ChannelDetails( channelId: channel?.id.value ?? channelId, title: channel?.title ?? about?.title ?? '', description: about?.description ?? '', thumbnailUrl: channel?.logoUrl ?? (about != null && about.thumbnails.isNotEmpty ? about.thumbnails.last.url.toString() : ''), joinDateLabel: about?.joinDate, viewCount: about?.viewCount, subscriberCount: channel?.subscribersCount, hiddenSubscriberCount: channel?.subscribersCount == null, country: about?.country, ); final windowsDetails = await WindowsChannelMetadataService.instance .getDetails(channelId); final merged = _mergeChannelDetails( publicDetails, windowsDetails, ); // ignore: avoid_print print( '[CHANNEL-META][18.5.2] id=$channelId ' 'subs=${merged.subscriberCount ?? 'NA'} ' 'views=${merged.viewCount ?? 'NA'} ' 'videos=${merged.videoCount ?? 'NA'} ' 'date=${merged.publishedAt?.toIso8601String() ?? merged.joinDateLabel ?? 'NA'}', ); return merged; } ChannelDetails _mergeChannelDetails( ChannelDetails primary, ChannelDetails? fallback, ) { if (fallback == null) { return primary; } String chooseString( String first, String second, ) { return first.trim().isNotEmpty ? first : second; } return ChannelDetails( channelId: chooseString( primary.channelId, fallback.channelId, ), title: chooseString( primary.title, fallback.title, ), description: chooseString( primary.description, fallback.description, ), thumbnailUrl: chooseString( primary.thumbnailUrl, fallback.thumbnailUrl, ), publishedAt: primary.publishedAt ?? fallback.publishedAt, joinDateLabel: primary.joinDateLabel ?? fallback.joinDateLabel, viewCount: primary.viewCount ?? fallback.viewCount, subscriberCount: primary.subscriberCount ?? fallback.subscriberCount, hiddenSubscriberCount: primary.subscriberCount == null && fallback.subscriberCount == null, videoCount: primary.videoCount ?? fallback.videoCount, country: primary.country ?? fallback.country, ); } VideoSorting _videoSortingFor(ChannelUploadSort sort) { return switch (sort) { ChannelUploadSort.newest => VideoSorting.newest, ChannelUploadSort.popularity => VideoSorting.popularity, ChannelUploadSort.oldest => VideoSorting.oldest, }; } /// Loads ONE public channel page at a time. /// /// youtube_explode_dart exposes ChannelUploadsList.nextPage(), so the UI /// can keep the first ~30 entries fast and explicitly request more instead /// of pretending that the first page is the whole channel. Future getChannelUploadsPage( String channelId, { ChannelUploadSort sort = ChannelUploadSort.newest, bool reset = false, }) async { final normalizedId = channelId.trim(); if (normalizedId.isEmpty) { return const ChannelUploadBatch( videos: [], hasMore: false, ); } final mustReset = reset || _channelUploadsPage == null || _channelUploadsChannelId != normalizedId || _channelUploadsSort != sort; ChannelUploadsList? page; if (mustReset) { _channelUploadsPage = await _youtube.channels.getUploadsFromPage( normalizedId, videoSorting: _videoSortingFor(sort), ); _channelUploadsChannelId = normalizedId; _channelUploadsSort = sort; _channelUploadsExhausted = false; page = _channelUploadsPage; } else { if (_channelUploadsExhausted) { return const ChannelUploadBatch( videos: [], hasMore: false, ); } final current = _channelUploadsPage; if (current == null) { return const ChannelUploadBatch( videos: [], hasMore: false, ); } page = await current.nextPage(); if (page == null) { _channelUploadsExhausted = true; return const ChannelUploadBatch( videos: [], hasMore: false, ); } _channelUploadsPage = page; } final resolvedPage = page; if (resolvedPage == null) { _channelUploadsExhausted = true; return const ChannelUploadBatch( videos: [], hasMore: false, ); } final videos = resolvedPage .map(_toVideoItem) .toList(growable: false); // YouTube channel pages are normally around 30 items. There is no public // `hasNextPage` flag on ChannelUploadsList, only nextPage(). A short page // is therefore considered final; an exact full page keeps the button. // If the last page contains exactly 30 entries, the next click simply // receives null and hides the button without losing any data. final hasMore = videos.length >= 30; if (!hasMore) { _channelUploadsExhausted = true; } // ignore: avoid_print print( '[CHANNEL-VIDEOS][18.7.20] ' 'channel=$normalizedId ' 'sort=${sort.name} ' 'pageVideos=${videos.length} ' 'hasMore=$hasMore', ); return ChannelUploadBatch( videos: videos, hasMore: hasMore, ); } /// Streams the full public upload catalogue when a caller really needs it. /// The channel UI uses [getChannelUploadsPage] instead for progressive load. Stream getChannelUploads( String channelId, { ChannelUploadSort sort = ChannelUploadSort.newest, }) async* { var page = await _youtube.channels.getUploadsFromPage( channelId, videoSorting: _videoSortingFor(sort), ); while (true) { for (final video in page) { yield _toVideoItem(video); } final next = await page.nextPage(); if (next == null) { break; } page = next; } } Future getPlayableMuxedUrl( String videoId, ) async { // Stable progressive fallback used by the existing player. final manifest = await _youtube.videos.streams.getManifest(videoId); final muxed = manifest.muxed.toList(growable: false); if (muxed.isEmpty) { throw StateError( 'Aucun flux audio + vidéo compatible n’est disponible.', ); } final sorted = [...muxed] ..sort((a, b) { final height = b.videoResolution.height.compareTo( a.videoResolution.height, ); if (height != 0) { return height; } return b.bitrate.bitsPerSecond.compareTo( a.bitrate.bitsPerSecond, ); }); return sorted.first.url.toString(); } VideoItem _toVideoItem(Video video) { return VideoItem( id: video.id.value, title: video.title, author: video.author, channelId: video.channelId.value, duration: video.duration, viewCount: video.engagement.viewCount, likeCount: video.engagement.likeCount, description: video.description, uploadDate: video.uploadDate, thumbnailUrl: 'https://i.ytimg.com/vi/${video.id.value}/hqdefault.jpg', ); } SubscriptionChannel _toSubscriptionChannel( Channel channel, ) { return SubscriptionChannel( channelId: channel.id.value, title: channel.title, thumbnailUrl: channel.logoUrl, ); } void close() { _youtube.close(); } }