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