Files
youtube_personal/lib/services/windows_channel_metadata_service.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)}…';
}
}