482 lines
13 KiB
Dart
482 lines
13 KiB
Dart
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<VideoItem> videos;
|
||
final bool hasMore;
|
||
}
|
||
|
||
class YoutubeService {
|
||
final YoutubeExplode _youtube = YoutubeExplode();
|
||
|
||
ChannelUploadsList? _channelUploadsPage;
|
||
String? _channelUploadsChannelId;
|
||
ChannelUploadSort? _channelUploadsSort;
|
||
bool _channelUploadsExhausted = false;
|
||
|
||
Future<List<VideoItem>> 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<List<VideoItem>> _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 = <VideoItem>[];
|
||
for (final line in lines) {
|
||
try {
|
||
final json = jsonDecode(line) as Map<String, dynamic>;
|
||
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<String, dynamic>)['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<VideoItem> 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<SubscriptionChannel> getChannelForVideo(
|
||
String videoId,
|
||
) async {
|
||
final channel =
|
||
await _youtube.channels.getByVideo(videoId);
|
||
|
||
return _toSubscriptionChannel(channel);
|
||
}
|
||
|
||
Future<SubscriptionChannel> getChannel(
|
||
String channelId,
|
||
) async {
|
||
final channel =
|
||
await _youtube.channels.get(channelId);
|
||
|
||
return _toSubscriptionChannel(channel);
|
||
}
|
||
|
||
Future<ChannelDetails?> 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<ChannelDetails> 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<ChannelUploadBatch> getChannelUploadsPage(
|
||
String channelId, {
|
||
ChannelUploadSort sort = ChannelUploadSort.newest,
|
||
bool reset = false,
|
||
}) async {
|
||
final normalizedId = channelId.trim();
|
||
|
||
if (normalizedId.isEmpty) {
|
||
return const ChannelUploadBatch(
|
||
videos: <VideoItem>[],
|
||
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: <VideoItem>[],
|
||
hasMore: false,
|
||
);
|
||
}
|
||
|
||
final current = _channelUploadsPage;
|
||
if (current == null) {
|
||
return const ChannelUploadBatch(
|
||
videos: <VideoItem>[],
|
||
hasMore: false,
|
||
);
|
||
}
|
||
|
||
page = await current.nextPage();
|
||
if (page == null) {
|
||
_channelUploadsExhausted = true;
|
||
return const ChannelUploadBatch(
|
||
videos: <VideoItem>[],
|
||
hasMore: false,
|
||
);
|
||
}
|
||
|
||
_channelUploadsPage = page;
|
||
}
|
||
|
||
final resolvedPage = page;
|
||
if (resolvedPage == null) {
|
||
_channelUploadsExhausted = true;
|
||
return const ChannelUploadBatch(
|
||
videos: <VideoItem>[],
|
||
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<VideoItem> 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<String> 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();
|
||
}
|
||
}
|