325 lines
7.5 KiB
Dart
325 lines
7.5 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../models/channel_details.dart';
|
|
|
|
/// Robust public channel metadata fallback for Windows.
|
|
///
|
|
/// youtube_explode_dart currently returns enough metadata for some channels,
|
|
/// but on current YouTube layouts subscriber/about fields may be null. The
|
|
/// project already ships a current yt-dlp nightly for HD playback, so Windows
|
|
/// can reuse it without adding another dependency or a Google login.
|
|
class WindowsChannelMetadataService {
|
|
WindowsChannelMetadataService._();
|
|
|
|
static final WindowsChannelMetadataService instance =
|
|
WindowsChannelMetadataService._();
|
|
|
|
final Map<String, Future<ChannelDetails?>> _cache =
|
|
<String, Future<ChannelDetails?>>{};
|
|
|
|
Future<ChannelDetails?> getDetails(
|
|
String channelId,
|
|
) {
|
|
if (!Platform.isWindows || channelId.trim().isEmpty) {
|
|
return Future<ChannelDetails?>.value(null);
|
|
}
|
|
|
|
return _cache.putIfAbsent(
|
|
channelId,
|
|
() => _load(channelId),
|
|
);
|
|
}
|
|
|
|
void invalidate(String channelId) {
|
|
_cache.remove(channelId);
|
|
}
|
|
|
|
Future<ChannelDetails?> _load(
|
|
String channelId,
|
|
) async {
|
|
final ytDlp = _resolveYtDlp();
|
|
|
|
if (!await ytDlp.exists()) {
|
|
debugPrint(
|
|
'[CHANNEL-YTDLP][18.5.2] yt-dlp introuvable: ${ytDlp.path}',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
Map<String, dynamic>? videosData;
|
|
Map<String, dynamic>? aboutData;
|
|
|
|
try {
|
|
videosData = await _extract(
|
|
ytDlp,
|
|
'https://www.youtube.com/channel/$channelId/videos',
|
|
);
|
|
} catch (error) {
|
|
debugPrint(
|
|
'[CHANNEL-YTDLP][18.5.2] /videos échoué: $error',
|
|
);
|
|
}
|
|
|
|
// Current yt-dlp channel extraction normally provides followers on the
|
|
// channel URL. The About route can additionally expose channel views and
|
|
// description on layouts where /videos omits them. It is intentionally
|
|
// best-effort because YouTube has changed the About page several times.
|
|
if (_intValue(videosData?['view_count']) == null ||
|
|
_stringValue(videosData?['description']) == null) {
|
|
try {
|
|
aboutData = await _extract(
|
|
ytDlp,
|
|
'https://www.youtube.com/channel/$channelId/about',
|
|
);
|
|
} catch (error) {
|
|
debugPrint(
|
|
'[CHANNEL-YTDLP][18.5.2] /about ignoré: $error',
|
|
);
|
|
}
|
|
}
|
|
|
|
final data = _mergeMaps(
|
|
primary: videosData,
|
|
secondary: aboutData,
|
|
);
|
|
|
|
if (data == null) {
|
|
return null;
|
|
}
|
|
|
|
final subscribers =
|
|
_intValue(data['channel_follower_count']);
|
|
final views = _intValue(data['view_count']);
|
|
final rawVideoCount =
|
|
_intValue(data['playlist_count']);
|
|
final videoCount =
|
|
rawVideoCount != null && rawVideoCount > 0
|
|
? rawVideoCount
|
|
: null;
|
|
|
|
final title =
|
|
_stringValue(data['channel']) ??
|
|
_stringValue(data['uploader']) ??
|
|
_stringValue(data['title']) ??
|
|
'';
|
|
|
|
final description =
|
|
_stringValue(data['description']) ?? '';
|
|
|
|
final thumbnail = _bestThumbnail(
|
|
data['thumbnails'],
|
|
);
|
|
|
|
debugPrint(
|
|
'[CHANNEL-YTDLP][18.5.2] channel=$channelId '
|
|
'subs=${subscribers ?? 'NA'} '
|
|
'views=${views ?? 'NA'} '
|
|
'videos=${videoCount ?? 'NA'} '
|
|
'title=${title.isEmpty ? 'NA' : title}',
|
|
);
|
|
|
|
if (subscribers == null &&
|
|
views == null &&
|
|
videoCount == null &&
|
|
title.isEmpty &&
|
|
description.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
return ChannelDetails(
|
|
channelId: channelId,
|
|
title: title,
|
|
description: description,
|
|
thumbnailUrl: thumbnail ?? '',
|
|
viewCount: views,
|
|
subscriberCount: subscribers,
|
|
hiddenSubscriberCount: subscribers == null,
|
|
videoCount: videoCount,
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _extract(
|
|
File ytDlp,
|
|
String url,
|
|
) async {
|
|
final args = <String>[
|
|
'--ignore-config',
|
|
'--quiet',
|
|
'--no-warnings',
|
|
'--encoding',
|
|
'utf-8',
|
|
'--socket-timeout',
|
|
'15',
|
|
'--retries',
|
|
'2',
|
|
'--flat-playlist',
|
|
'--playlist-items',
|
|
'0',
|
|
'--skip-download',
|
|
'--dump-single-json',
|
|
url,
|
|
];
|
|
|
|
final result = await Process.run(
|
|
ytDlp.path,
|
|
args,
|
|
workingDirectory: ytDlp.parent.path,
|
|
runInShell: false,
|
|
stdoutEncoding: utf8,
|
|
stderrEncoding: utf8,
|
|
).timeout(
|
|
const Duration(seconds: 25),
|
|
);
|
|
|
|
if (result.exitCode != 0) {
|
|
throw StateError(
|
|
_compactError(result.stderr?.toString() ?? ''),
|
|
);
|
|
}
|
|
|
|
final stdout = result.stdout?.toString().trim() ?? '';
|
|
|
|
if (stdout.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
// Normally --dump-single-json produces one JSON document. If yt-dlp ever
|
|
// prints a harmless prefix, recover from the first opening brace.
|
|
final jsonStart = stdout.indexOf('{');
|
|
final jsonText = jsonStart >= 0
|
|
? stdout.substring(jsonStart)
|
|
: stdout;
|
|
|
|
final decoded = jsonDecode(jsonText);
|
|
|
|
if (decoded is Map) {
|
|
return Map<String, dynamic>.from(decoded);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
Map<String, dynamic>? _mergeMaps({
|
|
required Map<String, dynamic>? primary,
|
|
required Map<String, dynamic>? secondary,
|
|
}) {
|
|
if (primary == null && secondary == null) {
|
|
return null;
|
|
}
|
|
|
|
final merged = <String, dynamic>{};
|
|
|
|
if (secondary != null) {
|
|
merged.addAll(secondary);
|
|
}
|
|
|
|
if (primary != null) {
|
|
// Prefer non-null values from /videos, but don't overwrite useful
|
|
// About metadata with nulls.
|
|
for (final entry in primary.entries) {
|
|
if (entry.value != null) {
|
|
merged[entry.key] = entry.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
File _resolveYtDlp() {
|
|
final executableDirectory =
|
|
File(Platform.resolvedExecutable).parent;
|
|
|
|
final bundled = File(
|
|
'${executableDirectory.path}'
|
|
'${Platform.pathSeparator}data'
|
|
'${Platform.pathSeparator}flutter_assets'
|
|
'${Platform.pathSeparator}assets'
|
|
'${Platform.pathSeparator}windows_hd'
|
|
'${Platform.pathSeparator}yt-dlp.exe',
|
|
);
|
|
|
|
if (bundled.existsSync()) {
|
|
return bundled;
|
|
}
|
|
|
|
return File(
|
|
'${Directory.current.path}'
|
|
'${Platform.pathSeparator}assets'
|
|
'${Platform.pathSeparator}windows_hd'
|
|
'${Platform.pathSeparator}yt-dlp.exe',
|
|
);
|
|
}
|
|
|
|
int? _intValue(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;
|
|
}
|
|
|
|
String? _stringValue(dynamic value) {
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
|
|
final text = value.toString().trim();
|
|
return text.isEmpty ? null : text;
|
|
}
|
|
|
|
String? _bestThumbnail(dynamic raw) {
|
|
if (raw is! List || raw.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
String? bestUrl;
|
|
int bestArea = -1;
|
|
|
|
for (final item in raw) {
|
|
if (item is! Map) {
|
|
continue;
|
|
}
|
|
|
|
final url = _stringValue(item['url']);
|
|
if (url == null) {
|
|
continue;
|
|
}
|
|
|
|
final width = _intValue(item['width']) ?? 0;
|
|
final height = _intValue(item['height']) ?? 0;
|
|
final area = width * height;
|
|
|
|
if (bestUrl == null || area > bestArea) {
|
|
bestUrl = url;
|
|
bestArea = area;
|
|
}
|
|
}
|
|
|
|
return bestUrl;
|
|
}
|
|
|
|
String _compactError(String value) {
|
|
final normalized = value
|
|
.replaceAll(RegExp(r'\s+'), ' ')
|
|
.trim();
|
|
|
|
if (normalized.length <= 350) {
|
|
return normalized;
|
|
}
|
|
|
|
return '${normalized.substring(0, 350)}…';
|
|
}
|
|
}
|