Files

482 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}