diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 387678e..61eeea8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,27 +1,42 @@ --- name: Bug report -about: Create a report to help us improve +about: Create a bug report, make sure to follow the template otherwise the issue might be closed. title: "[BUG]" labels: bug assignees: '' --- + + **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** -Include the code which doesn't work in the code markdown.. +Include the code which doesn't work in the code markdown... ```dart ``` **Stacktrace** Include here the stacktrace (if applicable). +Include also the full logs by adding the following lines to the start of your code: +```dart + Logger.root.level = Level.FINER; + Logger.root.onRecord.listen((e) { + print(e); + if (e.error != null) { + print(e.error); + print(e.stackTrace); + } + }); +``` +If too long please use a service like https://gist.github.com/ or https://pastebin.com/ **Enviroment: (please complete the following information):** - Enviroment: [Flutter o Dart VM] - Version [e.g. 2.8.4] - YoutubeExplode Version [e.g. ^2.5.0] + **Additional context** Add any other context about the problem here. diff --git a/example/example.dart b/example/example.dart index 8026520..a975bb3 100644 --- a/example/example.dart +++ b/example/example.dart @@ -1,35 +1,33 @@ -// ignore_for_file: avoid_print -import 'package:logging/logging.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; Future main() async { - Logger.root.level = Level.FINER; - Logger.root.onRecord.listen((record) { - print( - '[${record.loggerName}] ${record.level.name}: ${record.time}: ${record.message}'); - if (record.error != null) { - print(record.error); - print(record.stackTrace); - } - }); - final yt = YoutubeExplode(); - final streamInfo = - await yt.videos.streams.getManifest('qCnQEsljFt0', ytClients: [ - YoutubeApiClient.ios, - YoutubeApiClient.android, - YoutubeApiClient.tv, - YoutubeApiClient.androidVr, - YoutubeApiClient.mediaConnect, - YoutubeApiClient.tvSimplyEmbedded - ]); - - print(streamInfo); - final my = streamInfo.hls.last; - // final file = File('local/vid.ts').openWrite(); - // await yt.videos.streams.get(my).pipe(file); - // await file.flush(); - // await file.close(); + + // Get the video metadata. + final video = await yt.videos.get('fRh_vgS2dFE'); + print(video.title); // ^ You can pass both video URLs or video IDs. + + final manifest = await yt.videos.streams.getManifest('fRh_vgS2dFE', + // You can also pass a list of preferred clients, otherwise the library will handle it: + ytClients: [ + YoutubeApiClient.ios, + YoutubeApiClient.androidVr, + ]); + + // Print all the available streams. + print(manifest); + + // Get the audio streams. + final audio = manifest.audioOnly; + + // Download it + final stream = yt.videos.streams.get(audio.first); + // then pipe the stream to a file... + + // Or you can use the url to stream it directly. + audio.first.url; // This is the audio stream url. + + // Make sure to handle the file extension properly. Especially m3u8 streams might require further processing. // Close the YoutubeExplode's http client. yt.close(); diff --git a/lib/src/reverse_engineering/hls_manifest.dart b/lib/src/reverse_engineering/hls_manifest.dart index 5a343e2..89272de 100644 --- a/lib/src/reverse_engineering/hls_manifest.dart +++ b/lib/src/reverse_engineering/hls_manifest.dart @@ -47,9 +47,25 @@ class HlsManifest { List<_StreamInfo> get streams { final streams = <_StreamInfo>[]; for (final video in videos) { + final type = video.params['TYPE']; + if (type != null && type != 'AUDIO') { + // TODO: type 'SUBTITLES' not supported. + continue; + } // The tag is the number after the /itag/ segment in the url final videoParts = video.url.split('/'); final itag = int.parse(videoParts[videoParts.indexOf('itag') + 1]); + + var bandwidth = int.tryParse(video.params['BANDWIDTH'] ?? ''); + final codecs = video.params['CODECS']?.replaceAll('"', '').split(','); + final audioCodec = codecs?.first; + final videoCodec = codecs?.last; + final resolution = video.params['RESOLUTION']?.split('x'); + final videoWidth = int.tryParse(resolution?[0] ?? ''); + final videoHeight = int.tryParse(resolution?[1] ?? ''); + final framerate = int.tryParse(video.params['FRAME-RATE'] ?? ''); + final audioItag = int.tryParse(video.params['AUDIO']?.trimQuotes() ?? ''); + // To find the file size look for the segments after the sgoap and sgovp parameters (audio + video) // Then URL decode the value and find the clen= parameter String? sgoap; @@ -68,21 +84,17 @@ class HlsManifest { if (sgoap != null) { audioClen = int.parse(RegExp(r'clen=(\d+)').firstMatch(sgoap)!.group(1)!); + if (bandwidth == null) { + final dur = double.parse( + RegExp(r'dur=(\d+\.\d+)').firstMatch(sgoap)!.group(1)!); + bandwidth = (audioClen / dur).round() * 8; + } } if (sgovp != null) { videoClen = int.parse(RegExp(r'clen=(\d+)').firstMatch(sgovp)!.group(1)!); } - final bandwidth = int.tryParse(video.params['BANDWIDTH'] ?? ''); - final codecs = video.params['CODECS']?.replaceAll('"', '').split(','); - final audioCodec = codecs?.first; - final videoCodec = codecs?.last; - final resolution = video.params['RESOLUTION']?.split('x'); - final videoWidth = int.tryParse(resolution?[0] ?? ''); - final videoHeight = int.tryParse(resolution?[1] ?? ''); - final framerate = int.tryParse(video.params['FRAME-RATE'] ?? ''); - streams.add( _StreamInfo( itag, @@ -97,6 +109,7 @@ class HlsManifest { bandwidth ?? 0, videoClen == null, audioClen == null, + audioItag, ), ); } @@ -123,17 +136,6 @@ class HlsManifest { } } -/* -BANDWIDTH=202508,CODECS="mp4a.40.5,avc1.4d400c",RESOLUTION=256x144,FRAME-RATE=30,VIDEO-RANGE=SDR,CLOSED-CAPTIONS=NONE -The hls stream provide the following information: -- BANDWIDTH: bytes -- CODECS: comma separated list of codecs -- RESOLUTION: width x height -- FRAME-RATE: frames per second - -from the url: -- itag - */ class _StreamInfo extends StreamInfoProvider { @override final int tag; @@ -177,6 +179,9 @@ class _StreamInfo extends StreamInfoProvider { @override final bool videoOnly; + @override + final int? audioItag; + _StreamInfo( this.tag, this.url, @@ -190,6 +195,7 @@ class _StreamInfo extends StreamInfoProvider { this.bitrate, this.audioOnly, this.videoOnly, + this.audioItag, ) : codec = MediaType('application', 'vnd.apple.mpegurl', { 'codecs': [ if (audioCodec != null) audioCodec, @@ -200,3 +206,7 @@ class _StreamInfo extends StreamInfoProvider { @override StreamSource get source => StreamSource.hls; } + +extension on String { + String trimQuotes() => substring(1, length - 1); +} diff --git a/lib/src/reverse_engineering/models/stream_info_provider.dart b/lib/src/reverse_engineering/models/stream_info_provider.dart index 33ad8ff..ce759d1 100644 --- a/lib/src/reverse_engineering/models/stream_info_provider.dart +++ b/lib/src/reverse_engineering/models/stream_info_provider.dart @@ -67,4 +67,6 @@ abstract class StreamInfoProvider { bool get audioOnly => false; bool get videoOnly => false; + + int? get audioItag => null; } diff --git a/lib/src/videos/streams/mixins/hls_stream_info.dart b/lib/src/videos/streams/mixins/hls_stream_info.dart index 9929747..9ef0062 100644 --- a/lib/src/videos/streams/mixins/hls_stream_info.dart +++ b/lib/src/videos/streams/mixins/hls_stream_info.dart @@ -1,5 +1,6 @@ import '../../../../youtube_explode_dart.dart'; mixin HlsStreamInfo on StreamInfo { + /// The tag of the audio stream related to this stream. int? get audioItag => null; } diff --git a/lib/src/videos/streams/mixins/stream_info.dart b/lib/src/videos/streams/mixins/stream_info.dart index 71064e0..c209cab 100644 --- a/lib/src/videos/streams/mixins/stream_info.dart +++ b/lib/src/videos/streams/mixins/stream_info.dart @@ -65,34 +65,40 @@ extension StreamInfoIterableExt on Iterable { /// Print a formatted text of all the streams. Like youtube-dl -F option. String describe() { - final column = _Column(['format code', 'extension', 'resolution', 'note']); + final column = _Column([ + 'format code', + 'extension', + 'resolution', + 'quality', + 'bitrate', + 'size', + 'codecs', + 'info' + ]); // Sort the streams: // - First audio only streams. // - Then sort by resolution. - // - Then sort by extension. + // - Then sort by bitrate. final sorted = toList() ..sort((a, b) { final aIsOnlyAudio = (a is AudioOnlyStreamInfo) || (a is HlsAudioStreamInfo); final bIsOnlyAudio = (b is AudioOnlyStreamInfo) || (b is HlsAudioStreamInfo); + if (aIsOnlyAudio && !bIsOnlyAudio) { return -1; } else if (!aIsOnlyAudio && bIsOnlyAudio) { return 1; } - if (a is AudioStreamInfo && b is! AudioStreamInfo) { - return -1; - } else if (a is! AudioStreamInfo && b is AudioStreamInfo) { - return 1; - } + if (a is VideoStreamInfo && b is VideoStreamInfo) { final resolution = a.videoResolution.compareTo(b.videoResolution); if (resolution != 0) { return resolution; } } - return a.container.name.compareTo(b.container.name); + return a.bitrate.compareTo(b.bitrate); }); for (final e in sorted) { @@ -100,14 +106,16 @@ extension StreamInfoIterableExt on Iterable { e.tag, e.container.name, if (e is VideoStreamInfo) e.videoResolution else 'audio only', - e.qualityLabel, + if (e is VideoStreamInfo) + '${e.qualityLabel}${e.framerate.framesPerSecond}' + else + e.qualityLabel, e.bitrate, + e.size, e.codec.parameters['codecs'], - if (e is VideoStreamInfo) e.framerate, if (e is VideoOnlyStreamInfo || e is HlsVideoStreamInfo) 'video only', // if (e is AudioOnlyStreamInfo) 'audio only', if (e is MuxedStreamInfo || e is HlsMuxedStreamInfo) 'muxed', - e.size, if (e case AudioStreamInfo(:AudioTrack audioTrack)) audioTrack.displayName, ]); @@ -130,9 +138,19 @@ class _Column { String toString() { final headerLen = []; final buffer = StringBuffer(); - for (final e in header) { - headerLen.add(e.length + 2); - buffer.write('$e '); + + // Find the longest string for each column. + final longest = _values.map((e) { + return e + .reduce((value, element) => + value.length > element.length ? value : element) + .length; + }).toList(); + + for (final (i, e) in header.indexed) { + final pad = longest[i] > (e.length + 2) ? longest[i] - e.length + 2 : 2; + headerLen.add(e.length + pad); + buffer.write(e.padRight(e.length + pad)); } buffer.writeln(); @@ -143,6 +161,9 @@ class _Column { buffer.write(', $v'); continue; } + if (v.length > headerLen[i]) { + headerLen[i] = v.length + 2; + } buffer.write(v.padRight(headerLen[i])); } buffer.writeln(); diff --git a/lib/src/videos/streams/stream_client.dart b/lib/src/videos/streams/stream_client.dart index 622ab23..b53ef76 100644 --- a/lib/src/videos/streams/stream_client.dart +++ b/lib/src/videos/streams/stream_client.dart @@ -29,19 +29,23 @@ class StreamClient { /// /// See [YoutubeApiClient] for all the possible clients that can be set using the [ytClients] parameter. /// If [ytClients] is null the library automatically manages the clients, otherwise only the clients provided are used. - /// Currently by default the [YoutubeApiClient.android] and [YoutubeApiClient.ios] clients are used, if the extraction fails the [YoutubeApiClient.tvSimplyEmbedded] client is used instead. + /// Currently by default the [YoutubeApiClient.tv] and [YoutubeApiClient.ios] clients are used, if the extraction fails the [YoutubeApiClient.tvSimplyEmbedded] client is used instead. /// /// If [requireWatchPage] (default: true) is set to false the watch page is not used to extract the streams (so the process can be faster) but /// it COULD be less reliable (not tested thoroughly). /// If the extracted streams require signature decoding for which the watch page is required, the client will automatically fetch the watch page anyways (e.g. [YoutubeApiClient.tvSimplyEmbedded]). /// /// If the extraction fails an exception is thrown, to diagnose the issue enable the logging from the `logging` package, and open an issue with the output. - /// For example: + /// For example add at the beginning of your code: /// ```dart /// Logger.root.level = Level.FINER; - /// Logger.root.onRecord.listen(print); - /// // run yt related code ... - /// + /// Logger.root.onRecord.listen((e) { + /// print(e); + /// if (e.error != null) { + /// print(e.error); + /// print(e.stackTrace); + /// } + /// }); /// ``` Future getManifest(dynamic videoId, {@Deprecated( @@ -51,7 +55,7 @@ class StreamClient { bool requireWatchPage = true}) async { videoId = VideoId.fromString(videoId); final clients = - ytClients ?? [YoutubeApiClient.ios, YoutubeApiClient.android]; + ytClients ?? [YoutubeApiClient.ios, YoutubeApiClient.tv]; final uniqueStreams = LinkedHashSet( equals: (a, b) { @@ -292,7 +296,7 @@ class StreamClient { videoResolution, framerate, stream.codec, - 0, + stream.audioItag, ); } else { yield HlsMuxedStreamInfo( diff --git a/lib/src/videos/streams/types/hls/hls_audio_stream_info.dart b/lib/src/videos/streams/types/hls/hls_audio_stream_info.dart index 74903ea..eaebc2f 100644 --- a/lib/src/videos/streams/types/hls/hls_audio_stream_info.dart +++ b/lib/src/videos/streams/types/hls/hls_audio_stream_info.dart @@ -26,15 +26,19 @@ class HlsAudioStreamInfo with StreamInfo, AudioStreamInfo, HlsStreamInfo { final StreamContainer container; @override + + /// For HLS streams this is an approximation. final FileSize size; @override + + /// For HLS streams this is an approximation. final Bitrate bitrate; @override final String audioCodec; - /// This is always empty for hls streams + /// Always empty. @override List get fragments => const []; diff --git a/lib/src/videos/streams/types/hls/hls_muxed_stream_info.dart b/lib/src/videos/streams/types/hls/hls_muxed_stream_info.dart index e25d704..f497be2 100644 --- a/lib/src/videos/streams/types/hls/hls_muxed_stream_info.dart +++ b/lib/src/videos/streams/types/hls/hls_muxed_stream_info.dart @@ -28,9 +28,13 @@ class HlsMuxedStreamInfo final StreamContainer container; @override + + /// For HLS streams this is an approximation. final FileSize size; @override + + /// For HLS streams this is an approximation. final Bitrate bitrate; @override @@ -56,7 +60,7 @@ class HlsMuxedStreamInfo @override final Framerate framerate; - /// Muxed streams never have fragments. + /// Always empty. @override List get fragments => const []; diff --git a/lib/src/videos/streams/types/hls/hls_video_stream_info.dart b/lib/src/videos/streams/types/hls/hls_video_stream_info.dart index b0937f4..af4b4a1 100644 --- a/lib/src/videos/streams/types/hls/hls_video_stream_info.dart +++ b/lib/src/videos/streams/types/hls/hls_video_stream_info.dart @@ -25,9 +25,11 @@ class HlsVideoStreamInfo with StreamInfo, VideoStreamInfo, HlsStreamInfo { @override final StreamContainer container; + /// For HLS streams this is an approximation. @override final FileSize size; + /// For HLS streams this is an approximation. @override final Bitrate bitrate; @@ -51,7 +53,7 @@ class HlsVideoStreamInfo with StreamInfo, VideoStreamInfo, HlsStreamInfo { @override final Framerate framerate; - /// Muxed streams never have fragments. + /// Always empty. @override List get fragments => const []; @@ -64,6 +66,9 @@ class HlsVideoStreamInfo with StreamInfo, VideoStreamInfo, HlsStreamInfo { @override final String qualityLabel; + @override + final int? audioItag; + /// Initializes an instance of [HlsVideoStreamInfo] HlsVideoStreamInfo( this.videoId, @@ -89,7 +94,4 @@ class HlsVideoStreamInfo with StreamInfo, VideoStreamInfo, HlsStreamInfo { @override Map toJson() => _$HlsVideoStreamInfoToJson(this); - - @override - final int audioItag; } diff --git a/lib/src/videos/youtube_api_client.dart b/lib/src/videos/youtube_api_client.dart index 9541523..0970900 100644 --- a/lib/src/videos/youtube_api_client.dart +++ b/lib/src/videos/youtube_api_client.dart @@ -62,6 +62,7 @@ class YoutubeApiClient { }); /// This provides also muxed streams but seems less reliable than [ios]. + /// If you require an android client use [androidVr] instead. static const android = YoutubeApiClient({ 'context': { 'client': {