281 lines
8.3 KiB
Dart
281 lines
8.3 KiB
Dart
import 'package:collection/collection.dart';
|
|
import 'package:http_parser/http_parser.dart';
|
|
import 'package:xml/xml.dart' as xml;
|
|
|
|
import '../extensions/helpers_extension.dart';
|
|
import '../retry.dart';
|
|
import 'models/fragment.dart';
|
|
import 'models/stream_info_provider.dart';
|
|
import 'youtube_http_client.dart';
|
|
|
|
///
|
|
class DashManifest {
|
|
static final _urlSignatureExp = RegExp(r'/s/(.*?)(?:/|$)');
|
|
|
|
final xml.XmlDocument _root;
|
|
|
|
///
|
|
late final Iterable<_StreamInfo> streams = parseMDP(_root);
|
|
|
|
///
|
|
DashManifest(this._root);
|
|
|
|
///
|
|
// ignore: deprecated_member_use
|
|
DashManifest.parse(String raw) : _root = xml.parse(raw);
|
|
|
|
///
|
|
static Future<DashManifest> get(YoutubeHttpClient httpClient, dynamic url) {
|
|
return retry(httpClient, () async {
|
|
var raw = await httpClient.getString(url);
|
|
return DashManifest.parse(raw);
|
|
});
|
|
}
|
|
|
|
///
|
|
static String? getSignatureFromUrl(String url) =>
|
|
_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 {
|
|
@override
|
|
final int tag;
|
|
|
|
@override
|
|
final String url;
|
|
|
|
@override
|
|
final MediaType codec;
|
|
|
|
@override
|
|
String get container => codec.subtype;
|
|
|
|
bool get isAudioOnly => codec.type == 'audio';
|
|
|
|
@override
|
|
String? get audioCodec => isAudioOnly ? codec.subtype : null;
|
|
|
|
@override
|
|
String? get videoCodec => isAudioOnly ? null : codec.subtype;
|
|
|
|
@override
|
|
@Deprecated('Use qualityLabel')
|
|
String get videoQualityLabel => qualityLabel;
|
|
|
|
@override
|
|
late final String qualityLabel = 'DASH';
|
|
|
|
@override
|
|
final int? videoWidth;
|
|
|
|
@override
|
|
final int? videoHeight;
|
|
|
|
@override
|
|
final int? framerate;
|
|
|
|
@override
|
|
final List<Fragment> fragments;
|
|
|
|
@override
|
|
StreamSource get source => StreamSource.dash;
|
|
|
|
_StreamInfo(this.tag, this.url, this.codec, this.videoWidth, this.videoHeight,
|
|
this.framerate, this.fragments);
|
|
}
|
|
|
|
class _SegmentTimeline {
|
|
final List<_S> segments;
|
|
|
|
const _SegmentTimeline(this.segments);
|
|
}
|
|
|
|
class _S {
|
|
final int d;
|
|
final int r;
|
|
|
|
const _S(this.d, this.r);
|
|
}
|
|
|
|
class _MsInfo {
|
|
int startNumber = 1;
|
|
|
|
String? initializationUrl;
|
|
_SegmentTimeline? segmentTimeline;
|
|
List<String>? segmentUrls;
|
|
List<Fragment>? fragments;
|
|
|
|
_MsInfo();
|
|
|
|
_MsInfo copy() {
|
|
final v = _MsInfo();
|
|
|
|
v.initializationUrl = initializationUrl;
|
|
v.segmentTimeline = segmentTimeline;
|
|
v.segmentUrls = segmentUrls;
|
|
v.fragments = fragments;
|
|
v.startNumber = startNumber;
|
|
|
|
return v;
|
|
}
|
|
}
|