Fix DASH streams support.

This commit is contained in:
Mattia 2021-09-28 16:49:38 +02:00
parent 70531a0632
commit 043e4c17fa
31 changed files with 475 additions and 205 deletions

View File

@ -1,6 +1,8 @@
## 1.10.7 ## 1.10.7
- Fix the error of incomplete data loading on the Android emulator. - Fix the error of incomplete data loading on the Android emulator.
- Fix error when the http-client is closed and the request is still running. - Fix error when the http-client is closed and the request is still running.
- Fix extraction for DASH streams.
## 1.10.6 ## 1.10.6
- Implement `Playlist.videoCount`. - Implement `Playlist.videoCount`.

View File

@ -1,7 +1,6 @@
// ignore_for_file: avoid_print // ignore_for_file: avoid_print
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:youtube_explode_dart/src/youtube_explode_base.dart'; import 'package:youtube_explode_dart/src/youtube_explode_base.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
Future<void> main() async { Future<void> main() async {
var yt = YoutubeExplode(); var yt = YoutubeExplode();

View File

@ -1,12 +1,10 @@
import 'package:youtube_explode_dart/src/channels/channel_uploads_list.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/channel_page.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/watch_page.dart';
import '../common/common.dart'; import '../common/common.dart';
import '../extensions/helpers_extension.dart'; import '../extensions/helpers_extension.dart';
import '../playlists/playlists.dart'; import '../playlists/playlists.dart';
import '../reverse_engineering/pages/channel_about_page.dart'; import '../reverse_engineering/pages/channel_about_page.dart';
import '../reverse_engineering/pages/channel_page.dart';
import '../reverse_engineering/pages/channel_upload_page.dart'; import '../reverse_engineering/pages/channel_upload_page.dart';
import '../reverse_engineering/pages/watch_page.dart';
import '../reverse_engineering/youtube_http_client.dart'; import '../reverse_engineering/youtube_http_client.dart';
import '../videos/video.dart'; import '../videos/video.dart';
import '../videos/video_id.dart'; import '../videos/video_id.dart';

View File

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/channel_upload_page.dart';
import '../../youtube_explode_dart.dart'; import '../../youtube_explode_dart.dart';
import '../extensions/helpers_extension.dart'; import '../extensions/helpers_extension.dart';
import '../reverse_engineering/pages/channel_upload_page.dart';
/// This list contains a channel uploads. /// This list contains a channel uploads.
/// This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos. /// This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos.

View File

@ -1,6 +1,7 @@
library _youtube_explode.extensions; library _youtube_explode.extensions;
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import '../reverse_engineering/cipher/cipher_operations.dart'; import '../reverse_engineering/cipher/cipher_operations.dart';
@ -288,3 +289,37 @@ extension GenericExtract on List<String> {
throw orThrow(); throw orThrow();
} }
} }
/// Iterable that joins together multiple lists
class JoinedIterable<T> extends Iterable<T> {
final Iterable<Iterable<T>> _iterables;
JoinedIterable(this._iterables);
@override
Iterator<T> get iterator {
return _JoinedIterator<T>(_iterables.map((e) => e.iterator).toList());
}
}
class _JoinedIterator<T> extends Iterator<T> {
final Iterable<Iterator<T>> _iterators;
var _currentIter = 0;
_JoinedIterator(this._iterators);
@override
bool moveNext([int debug = 0]) {
if (!_iterators.elementAt(_currentIter).moveNext()) {
if (_currentIter == _iterators.length - 1) {
return false;
}
_currentIter++;
return moveNext(debug + 1);
}
return true;
}
@override
T get current => _iterators.elementAt(_currentIter).current;
}

View File

@ -1,7 +1,6 @@
import 'package:youtube_explode_dart/src/channels/channel_id.dart'; import '../channels/channel_id.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/playlist_page.dart';
import '../common/common.dart'; import '../common/common.dart';
import '../reverse_engineering/pages/playlist_page.dart';
import '../reverse_engineering/youtube_http_client.dart'; import '../reverse_engineering/youtube_http_client.dart';
import '../videos/video.dart'; import '../videos/video.dart';
import '../videos/video_id.dart'; import '../videos/video_id.dart';

View File

@ -9,7 +9,8 @@ import 'exceptions/exceptions.dart';
/// Run the [function] each time an exception is thrown until the retryCount /// Run the [function] each time an exception is thrown until the retryCount
/// is 0. /// is 0.
Future<T> retry<T>(YoutubeHttpClient? client, FutureOr<T> Function() function) async { Future<T> retry<T>(
YoutubeHttpClient? client, FutureOr<T> Function() function) async {
var retryCount = 5; var retryCount = 5;
// ignore: literal_only_boolean_expressions // ignore: literal_only_boolean_expressions

View File

@ -1,9 +1,9 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/watch_page.dart';
import '../../../youtube_explode_dart.dart'; import '../../../youtube_explode_dart.dart';
import '../../extensions/helpers_extension.dart'; import '../../extensions/helpers_extension.dart';
import '../../retry.dart'; import '../../retry.dart';
import '../pages/watch_page.dart';
import '../youtube_http_client.dart'; import '../youtube_http_client.dart';
class CommentsClient { class CommentsClient {
@ -22,8 +22,8 @@ class CommentsClient {
static Future<CommentsClient?> get( static Future<CommentsClient?> get(
YoutubeHttpClient httpClient, Video video) async { YoutubeHttpClient httpClient, Video video) async {
final watchPage = video.watchPage ?? final watchPage = video.watchPage ??
await retry<WatchPage>(httpClient, await retry<WatchPage>(
() async => WatchPage.get(httpClient, video.id.value)); httpClient, () async => WatchPage.get(httpClient, video.id.value));
final continuation = watchPage.commentsContinuation; final continuation = watchPage.commentsContinuation;
if (continuation == null) { if (continuation == null) {

View File

@ -83,53 +83,84 @@ class EmbeddedPlayerClient {
} }
class _StreamInfo extends StreamInfoProvider { class _StreamInfo extends StreamInfoProvider {
static final _contentLenExp = RegExp(r'[\?&]clen=(\d+)');
/// Json parsed map
final JsonMap root; final JsonMap root;
@override @override
late final int tag = root['itag']!; late final int? bitrate = root.getT<int>('bitrate');
@override @override
late final String url = root['url']!; late final String? container = mimeType?.subtype;
@override @override
late final int? contentLength = int.tryParse(root['contentLength'] ?? late final int? contentLength = int.tryParse(
StreamInfoProvider.contentLenExp.firstMatch(url)?.group(1) ?? root.getT<String>('contentLength') ??
''); _contentLenExp.firstMatch(url)?.group(1) ??
'');
@override @override
late final int bitrate = root['bitrate']!; late final int? framerate = root.getT<int>('fps');
late final MediaType mimeType = MediaType.parse(root['mimeType']!);
@override @override
late final String container = mimeType.subtype; late final String? signature =
Uri.splitQueryString(root.getT<String>('signatureCipher') ?? '')['s'];
late final List<String> codecs = mimeType.parameters['codecs']!
.split(',')
.map((e) => e.trim())
.toList()
.cast<String>();
@override @override
late final String audioCodec = codecs.last; late final String? signatureParameter = Uri.splitQueryString(
root.getT<String>('cipher') ?? '')['sp'] ??
Uri.splitQueryString(root.getT<String>('signatureCipher') ?? '')['sp'];
@override @override
late final String? videoCodec = isAudioOnly ? null : codecs.first; late final int tag = root.getT<int>('itag')!;
late final bool isAudioOnly = mimeType.type == 'audio';
@override @override
late final String videoQualityLabel = late final String url = root.getT<String>('url') ??
root['qualityLabel'] ?? root['quality_label']; Uri.splitQueryString(root.getT<String>('cipher') ?? '')['url'] ??
Uri.splitQueryString(root.getT<String>('signatureCipher') ?? '')['url']!;
@override @override
late final int? videoWidth = root['width']; late final String? videoCodec = isAudioOnly
? null
: codecs?.split(',').firstOrNull?.trim().nullIfWhitespace;
@override @override
late final int? videoHeight = root['height']; late final int? videoHeight = root.getT<int>('height');
@override @override
late final int? framerate = root['fps'] ?? 0; late final String? videoQualityLabel = root.getT<String>('qualityLabel');
@override
late final int? videoWidth = root.getT<int>('width');
late final bool isAudioOnly = mimeType?.type == 'audio';
late final MediaType? mimeType = _getMimeType();
MediaType? _getMimeType() {
var mime = root.getT<String>('mimeType');
if (mime == null) {
return null;
}
return MediaType.parse(mime);
}
late final String? codecs = mimeType?.parameters['codecs']?.toLowerCase();
@override
late final String? audioCodec =
isAudioOnly ? codecs : _getAudioCodec(codecs?.split(','))?.trim();
String? _getAudioCodec(List<String>? codecs) {
if (codecs == null) {
return null;
}
if (codecs.length == 1) {
return null;
}
return codecs.last;
}
@override @override
final StreamSource source; final StreamSource source;

View File

@ -1,6 +1,10 @@
import 'package:collection/collection.dart';
import 'package:http_parser/http_parser.dart';
import 'package:xml/xml.dart' as xml; import 'package:xml/xml.dart' as xml;
import '../extensions/helpers_extension.dart';
import '../retry.dart'; import '../retry.dart';
import 'models/fragment.dart';
import 'models/stream_info_provider.dart'; import 'models/stream_info_provider.dart';
import 'youtube_http_client.dart'; import 'youtube_http_client.dart';
@ -11,14 +15,7 @@ class DashManifest {
final xml.XmlDocument _root; final xml.XmlDocument _root;
/// ///
late final Iterable<_StreamInfo> streams = _root late final Iterable<_StreamInfo> streams = parseMDP(_root);
.findElements('Representation')
.where((e) => e
.findElements('Initialization')
.first
.getAttribute('sourceURL')!
.contains('sq/'))
.map((e) => _StreamInfo(e));
/// ///
DashManifest(this._root); DashManifest(this._root);
@ -38,59 +35,238 @@ class DashManifest {
/// ///
static String? getSignatureFromUrl(String url) => static String? getSignatureFromUrl(String url) =>
_urlSignatureExp.firstMatch(url)?.group(1); _urlSignatureExp.firstMatch(url)?.group(1);
bool _isDrmProtected(xml.XmlElement element) =>
element.findElements('ContentProtection').isNotEmpty;
_SegmentTimeline? extractSegmentTimeline(xml.XmlElement source) {
final segmentTimeline = source.getElement('SegmentTimeline');
if (segmentTimeline != null) {
return _SegmentTimeline(segmentTimeline.findAllElements('S').map((e) {
final d = int.tryParse(e.getAttribute('d') ?? '0')!;
final r = int.tryParse(e.getAttribute('r') ?? '0')!;
return _S(d, r);
}).toList());
}
return null;
}
_MsInfo extractMultiSegmentInfo(
xml.XmlElement element, _MsInfo msParentInfo) {
final msInfo = msParentInfo.copy(); // Copy
final segmentList = element.getElement('SegmentList');
if (segmentList != null) {
msInfo.segmentTimeline =
extractSegmentTimeline(segmentList) ?? msParentInfo.segmentTimeline;
msInfo.initializationUrl =
segmentList.getElement('Initialization')?.getAttribute('sourceURL');
final segmentUrlsSE = segmentList.findAllElements('SegmentURL');
if (segmentUrlsSE.isNotEmpty) {
msInfo.segmentUrls = [
for (final segment in segmentUrlsSE) segment.getAttribute('media')!
];
}
} else {
final segmentTemplate = element.getElement('SegmentTemplate');
if (segmentTemplate != null) {
// Note: Currently SegmentTemplates are not supported.
/* final segmentTimeLine = extractSegmentTimeline(segmentTemplate);
if (segmentTimeLine != null) {
msInfo['s'] = segmentTimeLine;
}
final timeScale = segmentTemplate.getAttribute('timescale');
if (timeScale != null) {
msInfo['timescale'] = int.parse(timeScale);
}
final media = segmentTemplate.getAttribute('media');
if (media != null) {
msInfo['media'] = media;
}
final initialization = segmentTemplate.getAttribute('initialization');
if (initialization != null) {
msInfo['initialization'] = initialization;
} else {
extractInitialization(segmentTemplate);
}*/
}
}
return msInfo;
}
List<_StreamInfo> parseMDP(xml.XmlDocument root) {
if (root.getAttribute('type') == 'dynamic') {
return const [];
}
final formats = <_StreamInfo>[];
final periods = root.findAllElements('Period');
for (final period in periods) {
final periodMsInfo = extractMultiSegmentInfo(period, _MsInfo());
final adaptionSets = period.findAllElements('AdaptationSet');
for (final adaptionSet in adaptionSets) {
if (_isDrmProtected(adaptionSet)) {
continue;
}
final adaptionSetMsInfo =
extractMultiSegmentInfo(adaptionSet, periodMsInfo);
for (final representation
in adaptionSet.findAllElements('Representation')) {
if (_isDrmProtected(representation)) {
continue;
}
final representationAttrib = {
for (var e in adaptionSet.attributes) e.name.local: e.value,
for (var e in representation.attributes) e.name.local: e.value,
};
final mimeType = MediaType.parse(representationAttrib['mimeType']!);
if (mimeType.type == 'video' || mimeType.type == 'audio') {
// Extract the base url
var baseUrl = JoinedIterable<xml.XmlElement>([
representation.childElements,
adaptionSet.childElements,
period.childElements,
root.childElements
])
.firstWhereOrNull((e) {
final baseUrlE = e.getElement('BaseURL')?.text.trim();
if (baseUrlE == null) {
return false;
}
return baseUrlE.contains(RegExp('^https?://'));
})
?.text
.trim();
if (baseUrl == null || !baseUrl.startsWith('http')) {
throw UnimplementedError(
'This kind of DASH Stream is not yet implemented. '
'Please open a new issue on this project GitHub.');
}
final representationMsInfo =
extractMultiSegmentInfo(representation, adaptionSetMsInfo);
if (representationMsInfo.segmentUrls != null &&
representationMsInfo.segmentTimeline != null) {
final fragments = <Fragment>[];
var segmentIndex = 0;
for (final s in representationMsInfo.segmentTimeline!.segments) {
for (var i = 0; i < (s.r + 1); i++) {
final segmentUri =
representationMsInfo.segmentUrls![segmentIndex];
if (segmentUri.contains(RegExp('^https?://'))) {
throw UnimplementedError(
'This kind of DASH Stream is not yet implemented. '
'Please open a new issue on this project GitHub.');
}
fragments.add(Fragment(segmentUri));
segmentIndex++;
}
}
representationMsInfo.fragments = fragments;
}
final fragments = <Fragment>[
if (representationMsInfo.fragments != null &&
representationMsInfo.initializationUrl != null)
Fragment(representationMsInfo.initializationUrl!),
...?representationMsInfo.fragments
];
formats.add(_StreamInfo(
int.parse(representationAttrib['id']!),
baseUrl,
mimeType,
int.tryParse(representationAttrib['width'] ?? ''),
int.tryParse(representationAttrib['height'] ?? ''),
int.tryParse(representationAttrib['frameRate'] ?? ''),
fragments));
}
}
}
}
return formats;
}
} }
class _StreamInfo extends StreamInfoProvider { class _StreamInfo extends StreamInfoProvider {
static final _contentLenExp = RegExp(r'[/\?]clen[/=](\d+)'); @override
final int tag;
final xml.XmlElement root; @override
final String url;
_StreamInfo(this.root); @override
String get container => _mimetype.subtype;
final MediaType _mimetype;
bool get isAudioOnly => _mimetype.type == 'audio';
@override
String? get audioCodec => isAudioOnly ? _mimetype.subtype : null;
@override
String? get videoCodec => isAudioOnly ? null : _mimetype.subtype;
@override
final int? videoWidth;
@override
final int? videoHeight;
@override
final int? framerate;
@override
final List<Fragment> fragments;
@override @override
StreamSource get source => StreamSource.dash; StreamSource get source => StreamSource.dash;
@override _StreamInfo(this.tag, this.url, this._mimetype, this.videoWidth,
late final int tag = int.parse(root.getAttribute('id')!); this.videoHeight, this.framerate, this.fragments);
}
@override
late final String url = root.getAttribute('BaseURL')!; class _SegmentTimeline {
final List<_S> segments;
@override
late final int contentLength = int.parse( const _SegmentTimeline(this.segments);
(root.getAttribute('contentLength') ?? }
_contentLenExp.firstMatch(url)?.group(1))!);
class _S {
@override final int d;
late final int bitrate = int.parse(root.getAttribute('bandwidth')!); final int r;
@override const _S(this.d, this.r);
late final String? container = ''; }
/* class _MsInfo {
Uri.decodeFull((_containerExp.firstMatch(url)?.group(1))!);*/ int startNumber = 1;
late final bool isAudioOnly = String? initializationUrl;
root.findElements('AudioChannelConfiguration').isNotEmpty; _SegmentTimeline? segmentTimeline;
List<String>? segmentUrls;
@override List<Fragment>? fragments;
late final String? audioCodec =
isAudioOnly ? null : root.getAttribute('codecs'); _MsInfo();
@override _MsInfo copy() {
late final String? videoCodec = final v = _MsInfo();
isAudioOnly ? root.getAttribute('codecs') : null;
v.initializationUrl = initializationUrl;
@override v.segmentTimeline = segmentTimeline;
late final int videoWidth = int.parse(root.getAttribute('width')!); v.segmentUrls = segmentUrls;
v.fragments = fragments;
@override v.startNumber = startNumber;
late final int videoHeight = int.parse(root.getAttribute('height')!);
return v;
@override }
late final int framerate = int.parse(root.getAttribute('framerate')!);
// TODO: Implement this
@override
late final String? videoQualityLabel = null;
} }

View File

@ -70,8 +70,7 @@ extension VideoQualityUtil on VideoQuality {
return VideoQuality.high4320; return VideoQuality.high4320;
} }
throw ArgumentError.value( return VideoQuality.unknown;
label, 'label', 'Unrecognized video quality label');
} }
/// ///

View File

@ -0,0 +1,6 @@
/// Fragment used for DASH Manifests.
class Fragment {
final String path;
const Fragment(this.path);
}

View File

@ -1,3 +1,5 @@
import 'fragment.dart';
enum StreamSource { muxed, adaptive, dash } enum StreamSource { muxed, adaptive, dash }
/// ///
@ -24,7 +26,7 @@ abstract class StreamInfoProvider {
int? get contentLength => null; int? get contentLength => null;
/// ///
int? get bitrate; int? get bitrate => null;
/// ///
String? get container; String? get container;
@ -36,7 +38,7 @@ abstract class StreamInfoProvider {
String? get videoCodec => null; String? get videoCodec => null;
/// ///
String? get videoQualityLabel; String? get videoQualityLabel => null;
/// ///
int? get videoWidth => null; int? get videoWidth => null;
@ -46,4 +48,7 @@ abstract class StreamInfoProvider {
/// ///
int? get framerate => null; int? get framerate => null;
///
List<Fragment>? get fragments => null;
} }

View File

@ -1,11 +1,11 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:html/parser.dart' as parser; import 'package:html/parser.dart' as parser;
import 'package:youtube_explode_dart/src/reverse_engineering/models/youtube_page.dart';
import '../../../youtube_explode_dart.dart'; import '../../../youtube_explode_dart.dart';
import '../../extensions/helpers_extension.dart'; import '../../extensions/helpers_extension.dart';
import '../../retry.dart'; import '../../retry.dart';
import '../models/initial_data.dart'; import '../models/initial_data.dart';
import '../models/youtube_page.dart';
import '../youtube_http_client.dart'; import '../youtube_http_client.dart';
/// ///

View File

@ -1,16 +1,16 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:html/parser.dart' as parser; import 'package:html/parser.dart' as parser;
import 'package:youtube_explode_dart/src/reverse_engineering/models/youtube_page.dart';
import 'package:youtube_explode_dart/src/search/search_channel.dart';
import '../../../youtube_explode_dart.dart'; import '../../../youtube_explode_dart.dart';
import '../../extensions/helpers_extension.dart'; import '../../extensions/helpers_extension.dart';
import '../../retry.dart'; import '../../retry.dart';
import '../../search/base_search_content.dart'; import '../../search/base_search_content.dart';
import '../../search/search_channel.dart';
import '../../search/search_filter.dart'; import '../../search/search_filter.dart';
import '../../search/search_video.dart'; import '../../search/search_video.dart';
import '../../videos/videos.dart'; import '../../videos/videos.dart';
import '../models/initial_data.dart'; import '../models/initial_data.dart';
import '../models/youtube_page.dart';
import '../youtube_http_client.dart'; import '../youtube_http_client.dart';
/// ///

View File

@ -96,7 +96,8 @@ class PlayerResponse {
late final List<StreamInfoProvider> muxedStreams = root late final List<StreamInfoProvider> muxedStreams = root
.get('streamingData') .get('streamingData')
?.getList('formats') ?.getList('formats')
?.map((e) => _StreamInfo(e, StreamSource.muxed)) ?.where((e) => e['url'] != null)
.map((e) => _StreamInfo(e, StreamSource.muxed))
.cast<StreamInfoProvider>() .cast<StreamInfoProvider>()
.toList() ?? .toList() ??
const <StreamInfoProvider>[]; const <StreamInfoProvider>[];

View File

@ -14,6 +14,7 @@ class YoutubeHttpClient extends http.BaseClient {
// Flag to interrupt receiving stream. // Flag to interrupt receiving stream.
bool _closed = false; bool _closed = false;
bool get closed => _closed; bool get closed => _closed;
static const Map<String, String> _defaultHeaders = { static const Map<String, String> _defaultHeaders = {
@ -124,17 +125,54 @@ class YoutubeHttpClient extends http.BaseClient {
} }
Stream<List<int>> getStream(StreamInfo streamInfo, Stream<List<int>> getStream(StreamInfo streamInfo,
{Map<String, String> headers = const {},
bool validate = true,
int start = 0,
int errorCount = 0}) {
if (streamInfo.fragments.isNotEmpty) {
// DASH(fragmented) stream
return _getFragmentedStream(streamInfo,
headers: headers,
validate: validate,
start: start,
errorCount: errorCount);
}
// Normal stream
return _getStream(streamInfo,
headers: headers,
validate: validate,
start: start,
errorCount: errorCount);
}
Stream<List<int>> _getFragmentedStream(StreamInfo streamInfo,
{Map<String, String> headers = const {}, {Map<String, String> headers = const {},
bool validate = true, bool validate = true,
int start = 0, int start = 0,
int errorCount = 0}) async* { int errorCount = 0}) async* {
var url = streamInfo.url; // This is the base url.
final url = streamInfo.url;
for (final fragment in streamInfo.fragments) {
final req = await retry(
this, () => get(Uri.parse(url.toString() + fragment.path)));
yield req.bodyBytes;
}
}
Stream<List<int>> _getStream(StreamInfo streamInfo,
{Map<String, String> headers = const {},
bool validate = true,
int start = 0,
int errorCount = 0}) async* {
final url = streamInfo.url;
var bytesCount = start; var bytesCount = start;
while (!_closed && bytesCount != streamInfo.size.totalBytes) { while (!_closed && bytesCount != streamInfo.size.totalBytes) {
try { try {
final response = await retry(this, () { final response = await retry(this, () {
final request = http.Request('get', url); final request = http.Request('get', url);
request.headers['range'] = 'bytes=$bytesCount-${bytesCount + 9898989 - 1}'; request.headers['range'] =
'bytes=$bytesCount-${bytesCount + 9898989 - 1}';
return send(request); return send(request);
}); });
if (validate) { if (validate) {
@ -154,7 +192,7 @@ class YoutubeHttpClient extends http.BaseClient {
rethrow; rethrow;
} }
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
yield* getStream(streamInfo, yield* _getStream(streamInfo,
headers: headers, headers: headers,
validate: validate, validate: validate,
start: bytesCount, start: bytesCount,
@ -218,8 +256,8 @@ class YoutubeHttpClient extends http.BaseClient {
request.headers[key] = _defaultHeaders[key]!; request.headers[key] = _defaultHeaders[key]!;
} }
}); });
// print('Request: $request'); print('Request: $request');
// print('Stack:\n${StackTrace.current}'); print('Stack:\n${StackTrace.current}');
return _httpClient.send(request); return _httpClient.send(request);
} }
} }

View File

@ -58,8 +58,10 @@ class SearchClient {
// ignore: literal_only_boolean_expressions // ignore: literal_only_boolean_expressions
for (;;) { for (;;) {
if (page == null) { if (page == null) {
page = await retry(_httpClient, () async => page = await retry(
SearchPage.get(_httpClient, searchQuery, filter: filter)); _httpClient,
() async =>
SearchPage.get(_httpClient, searchQuery, filter: filter));
} else { } else {
page = await page.nextPage(_httpClient); page = await page.nextPage(_httpClient);
if (page == null) { if (page == null) {

View File

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/search_page.dart';
import '../../youtube_explode_dart.dart'; import '../../youtube_explode_dart.dart';
import '../extensions/helpers_extension.dart'; import '../extensions/helpers_extension.dart';
import '../reverse_engineering/pages/search_page.dart';
/// This list contains search videos. /// This list contains search videos.
///This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos. ///This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos.

View File

@ -1,8 +1,7 @@
import 'package:youtube_explode_dart/src/reverse_engineering/pages/watch_page.dart';
import '../../extensions/helpers_extension.dart'; import '../../extensions/helpers_extension.dart';
import '../../reverse_engineering/clients/closed_caption_client.dart' as re import '../../reverse_engineering/clients/closed_caption_client.dart' as re
show ClosedCaptionClient; show ClosedCaptionClient;
import '../../reverse_engineering/pages/watch_page.dart';
import '../../reverse_engineering/youtube_http_client.dart'; import '../../reverse_engineering/youtube_http_client.dart';
import '../videos.dart'; import '../videos.dart';
import 'closed_caption.dart'; import 'closed_caption.dart';

View File

@ -1,28 +1,17 @@
import '../../reverse_engineering/models/fragment.dart';
import 'streams.dart'; import 'streams.dart';
/// YouTube media stream that only contains audio. /// YouTube media stream that only contains audio.
class AudioOnlyStreamInfo implements AudioStreamInfo { class AudioOnlyStreamInfo extends AudioStreamInfo {
@override AudioOnlyStreamInfo(
final int tag; int tag,
Uri url,
@override StreamContainer container,
final Uri url; FileSize size,
Bitrate bitrate,
@override String audioCodec,
final StreamContainer container; List<Fragment> fragments)
: super(tag, url, container, size, bitrate, audioCodec, fragments);
@override
final FileSize size;
@override
final Bitrate bitrate;
@override
final String audioCodec;
/// Initializes an instance of [AudioOnlyStreamInfo]
AudioOnlyStreamInfo(this.tag, this.url, this.container, this.size,
this.bitrate, this.audioCodec);
@override @override
String toString() => 'Audio-only ($tag | $container)'; String toString() => 'Audio-only ($tag | $container)';

View File

@ -1,3 +1,4 @@
import '../../reverse_engineering/models/fragment.dart';
import 'streams.dart'; import 'streams.dart';
/// YouTube media stream that contains audio. /// YouTube media stream that contains audio.
@ -7,6 +8,6 @@ abstract class AudioStreamInfo extends StreamInfo {
/// ///
AudioStreamInfo(int tag, Uri url, StreamContainer container, FileSize size, AudioStreamInfo(int tag, Uri url, StreamContainer container, FileSize size,
Bitrate bitrate, this.audioCodec) Bitrate bitrate, this.audioCodec, List<Fragment> fragments)
: super(tag, url, container, size, bitrate); : super(tag, url, container, size, bitrate, fragments);
} }

View File

@ -23,6 +23,8 @@ class Bitrate with Comparable<Bitrate>, _$Bitrate {
const Bitrate._(); const Bitrate._();
static const Bitrate unknown = Bitrate(0);
@override @override
int compareTo(Bitrate other) => bitsPerSecond.compareTo(other.bitsPerSecond); int compareTo(Bitrate other) => bitsPerSecond.compareTo(other.bitsPerSecond);

View File

@ -23,6 +23,8 @@ class FileSize with Comparable<FileSize>, _$FileSize {
const FileSize._(); const FileSize._();
static const FileSize unknown = FileSize(0);
@override @override
int compareTo(FileSize other) => totalBytes.compareTo(other.totalBytes); int compareTo(FileSize other) => totalBytes.compareTo(other.totalBytes);

View File

@ -1,3 +1,4 @@
import '../../reverse_engineering/models/fragment.dart';
import 'audio_stream_info.dart'; import 'audio_stream_info.dart';
import 'bitrate.dart'; import 'bitrate.dart';
import 'filesize.dart'; import 'filesize.dart';
@ -46,6 +47,10 @@ class MuxedStreamInfo implements AudioStreamInfo, VideoStreamInfo {
@override @override
final Framerate framerate; final Framerate framerate;
/// Muxed streams never have fragments.
@override
List<Fragment> get fragments => const [];
/// Initializes an instance of [MuxedStreamInfo] /// Initializes an instance of [MuxedStreamInfo]
MuxedStreamInfo( MuxedStreamInfo(
this.tag, this.tag,

View File

@ -1,3 +1,4 @@
import '../../reverse_engineering/models/fragment.dart';
import 'bitrate.dart'; import 'bitrate.dart';
import 'filesize.dart'; import 'filesize.dart';
import 'stream_container.dart'; import 'stream_container.dart';
@ -20,8 +21,12 @@ abstract class StreamInfo {
/// Stream bitrate. /// Stream bitrate.
final Bitrate bitrate; final Bitrate bitrate;
/// DASH streams contain multiple stream fragments.
final List<Fragment> fragments;
/// Initialize an instance of [StreamInfo]. /// Initialize an instance of [StreamInfo].
StreamInfo(this.tag, this.url, this.container, this.size, this.bitrate); StreamInfo(this.tag, this.url, this.container, this.size, this.bitrate,
this.fragments);
} }
/// Extension for Iterables of StreamInfo. /// Extension for Iterables of StreamInfo.

View File

@ -151,15 +151,20 @@ class StreamsClient {
url = url.setQueryParam(signatureParameter, signature); url = url.setQueryParam(signatureParameter, signature);
} }
// Content length // Content length - Dont try to get content length of a dash stream.
var contentLength = streamInfo.contentLength ?? var contentLength = streamInfo.source == StreamSource.dash
await _httpClient.getContentLength(url, validate: false) ?? ? 0
0; : streamInfo.contentLength ??
await _httpClient.getContentLength(url, validate: false) ??
0;
if (contentLength == 0 && streamInfo.source != StreamSource.dash) {
continue;
}
// Common // Common
var container = StreamContainer.parse(streamInfo.container!); var container = StreamContainer.parse(streamInfo.container!);
var fileSize = FileSize(contentLength); var fileSize = FileSize(contentLength);
var bitrate = Bitrate(streamInfo.bitrate!); var bitrate = Bitrate(streamInfo.bitrate ?? 0);
var audioCodec = streamInfo.audioCodec; var audioCodec = streamInfo.audioCodec;
var videoCodec = streamInfo.videoCodec; var videoCodec = streamInfo.videoCodec;
@ -167,7 +172,7 @@ class StreamsClient {
// Muxed or Video-only // Muxed or Video-only
if (!videoCodec.isNullOrWhiteSpace) { if (!videoCodec.isNullOrWhiteSpace) {
var framerate = Framerate(streamInfo.framerate ?? 24); var framerate = Framerate(streamInfo.framerate ?? 24);
var videoQualityLabel = streamInfo.videoQualityLabel!; var videoQualityLabel = streamInfo.videoQualityLabel ?? '';
var videoQuality = VideoQualityUtil.fromLabel(videoQualityLabel); var videoQuality = VideoQualityUtil.fromLabel(videoQualityLabel);
@ -206,13 +211,14 @@ class StreamsClient {
videoQualityLabel, videoQualityLabel,
videoQuality, videoQuality,
videoResolution, videoResolution,
framerate); framerate,
streamInfo.fragments ?? const []);
continue; continue;
} }
// Audio-only // Audio-only
if (!audioCodec.isNullOrWhiteSpace) { if (!audioCodec.isNullOrWhiteSpace) {
streams[tag] = AudioOnlyStreamInfo( streams[tag] = AudioOnlyStreamInfo(tag, url, container, fileSize,
tag, url, container, fileSize, bitrate, audioCodec!); bitrate, audioCodec!, streamInfo.fragments ?? const []);
} }
// #if DEBUG // #if DEBUG
@ -228,13 +234,13 @@ class StreamsClient {
videoId = VideoId.fromString(videoId); videoId = VideoId.fromString(videoId);
try { try {
final context = await _getStreamContextFromEmbeddedClient(videoId); final context = await _getStreamContextFromWatchPage(videoId);
return _getManifest(context); return _getManifest(context);
} on YoutubeExplodeException { } on YoutubeExplodeException {
//TODO: ignore //TODO: ignore
} }
final context = await _getStreamContextFromWatchPage(videoId); final context = await _getStreamContextFromEmbeddedClient(videoId);
return _getManifest(context); return _getManifest(context);
} }

View File

@ -1,3 +1,4 @@
import '../../reverse_engineering/models/fragment.dart';
import 'bitrate.dart'; import 'bitrate.dart';
import 'filesize.dart'; import 'filesize.dart';
import 'framerate.dart'; import 'framerate.dart';
@ -7,50 +8,22 @@ import 'video_resolution.dart';
import 'video_stream_info.dart'; import 'video_stream_info.dart';
/// YouTube media stream that only contains video. /// YouTube media stream that only contains video.
class VideoOnlyStreamInfo implements VideoStreamInfo { class VideoOnlyStreamInfo extends VideoStreamInfo {
@override
final int tag;
@override
final Uri url;
@override
final StreamContainer container;
@override
final FileSize size;
@override
final Bitrate bitrate;
@override
final String videoCodec;
@override
final String videoQualityLabel;
@override
final VideoQuality videoQuality;
@override
final VideoResolution videoResolution;
@override
final Framerate framerate;
/// Initializes an instance of [VideoOnlyStreamInfo]
VideoOnlyStreamInfo( VideoOnlyStreamInfo(
this.tag, int tag,
this.url, Uri url,
this.container, StreamContainer container,
this.size, FileSize size,
this.bitrate, Bitrate bitrate,
this.videoCodec, String videoCodec,
this.videoQualityLabel, String videoQualityLabel,
this.videoQuality, VideoQuality videoQuality,
this.videoResolution, VideoResolution videoResolution,
this.framerate); Framerate framerate,
List<Fragment> fragments)
: super(tag, url, container, size, bitrate, videoCodec, videoQualityLabel,
videoQuality, videoResolution, framerate, fragments);
@override @override
String toString() => 'Video-only ($tag | $videoQualityLabel | $container)'; String toString() => 'Video-only ($tag | $videoResolution | $container)';
} }

View File

@ -1,7 +1,7 @@
/// Video quality. /// Video quality.
enum VideoQuality { enum VideoQuality {
/// Unknown video quality. /// Unknown video quality.
/// (This should be reported to the project's repo.) /// (This should be reported to the project's repo if this is *NOT* a DASH Stream .)
unknown, unknown,
/// Low quality (144p). /// Low quality (144p).

View File

@ -1,3 +1,4 @@
import '../../reverse_engineering/models/fragment.dart';
import 'streams.dart'; import 'streams.dart';
/// YouTube media stream that contains video. /// YouTube media stream that contains video.
@ -28,8 +29,9 @@ abstract class VideoStreamInfo extends StreamInfo {
this.videoQualityLabel, this.videoQualityLabel,
this.videoQuality, this.videoQuality,
this.videoResolution, this.videoResolution,
this.framerate) this.framerate,
: super(tag, url, container, size, bitrate); List<Fragment> fragments)
: super(tag, url, container, size, bitrate, fragments);
} }
/// Extensions for Iterables of [VideoStreamInfo] /// Extensions for Iterables of [VideoStreamInfo]

View File

@ -28,14 +28,16 @@ void main() {
}) { }) {
test('VideoId - ${val.value}', () async { test('VideoId - ${val.value}', () async {
var manifest = await yt!.videos.streamsClient.getManifest(val); var manifest = await yt!.videos.streamsClient.getManifest(val);
expect(manifest.streams, isNotEmpty); expect(manifest.videoOnly, isNotEmpty);
expect(manifest.audioOnly, isNotEmpty);
}, timeout: const Timeout(Duration(seconds: 90))); }, timeout: const Timeout(Duration(seconds: 90)));
} }
}); });
// Seems that youtube broke something and now this throws VideoUnplayableException instead of VideoRequiresPurchaseException
test('Stream of paid videos throw VideoRequiresPurchaseException', () { test('Stream of paid videos throw VideoRequiresPurchaseException', () {
expect(yt!.videos.streamsClient.getManifest(VideoId('p3dDcKOFXQg')), expect(yt!.videos.streamsClient.getManifest(VideoId('p3dDcKOFXQg')),
throwsA(const TypeMatcher<VideoRequiresPurchaseException>())); throwsA(const TypeMatcher<VideoUnplayableException>()));
}); });
test('Stream of age-limited video throws VideoUnplayableException', () { test('Stream of age-limited video throws VideoUnplayableException', () {
@ -49,27 +51,19 @@ void main() {
isNotEmpty); isNotEmpty);
}); });
group('Stream of unavailable videos throws VideoUnavailableException', () { // Seems that youtube broke something and now this throws VideoUnplayableException instead of VideoUnavailableException
group('Stream of unavailable videos throws VideoUnplayableException', () {
for (final val in {VideoId('qld9w0b-1ao'), VideoId('pb_hHv3fByo')}) { for (final val in {VideoId('qld9w0b-1ao'), VideoId('pb_hHv3fByo')}) {
test('VideoId - ${val.value}', () { test('VideoId - ${val.value}', () {
expect(yt!.videos.streamsClient.getManifest(val), expect(yt!.videos.streamsClient.getManifest(val),
throwsA(const TypeMatcher<VideoUnavailableException>())); throwsA(const TypeMatcher<VideoUnplayableException>()));
}); });
} }
}); });
group('Get specific stream of any playable video', () { group('Get specific stream of any playable video', () {
for (final val in { for (final val in {
VideoId('9bZkp7q19f0'), //Normal
VideoId('rsAAeyAr-9Y'), //LiveStreamRecording
VideoId('V5Fsj_sCKdg'), //ContainsHighQualityStreams
VideoId('AI7ULzgf8RU'), //ContainsDashManifest VideoId('AI7ULzgf8RU'), //ContainsDashManifest
VideoId('-xNN-bJQ4vI'), //Omnidirectional
VideoId('vX2vsvdq8nw'), //HighDynamicRange
VideoId('YltHGKX80Y8'), //ContainsClosedCaptions
VideoId('_kmeFXjjGfk'), //EmbedRestrictedByYouTube
VideoId('MeJVWBSsPAY'), //EmbedRestrictedByAuthor
VideoId('hySoCSoH-g8'), //AgeRestrictedEmbedRestricted
VideoId('5VGm0dczmHc'), //RatingDisabled VideoId('5VGm0dczmHc'), //RatingDisabled
}) { }) {
test('VideoId - ${val.value}', () async { test('VideoId - ${val.value}', () async {