Fix DASH streams support.
This commit is contained in:
parent
70531a0632
commit
043e4c17fa
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// Fragment used for DASH Manifests.
|
||||||
|
class Fragment {
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
const Fragment(this.path);
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
///
|
///
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
///
|
///
|
||||||
|
|
|
@ -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>[];
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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)';
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue