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> _cache = >{}; Future getDetails( String channelId, ) { if (!Platform.isWindows || channelId.trim().isEmpty) { return Future.value(null); } return _cache.putIfAbsent( channelId, () => _load(channelId), ); } void invalidate(String channelId) { _cache.remove(channelId); } Future _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? videosData; Map? 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?> _extract( File ytDlp, String url, ) async { final args = [ '--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.from(decoded); } return null; } Map? _mergeMaps({ required Map? primary, required Map? secondary, }) { if (primary == null && secondary == null) { return null; } final merged = {}; 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)}…'; } }