Skip to content

Commit

Permalink
Implement HLS streams parsing + add safari, tv, and androidVr yt clients
Browse files Browse the repository at this point in the history
  • Loading branch information
Hexer10 committed Oct 17, 2024
1 parent 673eeee commit 322bc97
Show file tree
Hide file tree
Showing 20 changed files with 738 additions and 41 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.3.2
- Implement HLS streams parsing.
- Add safari, tv, and androidVr yt clients.

## 2.3.1
- Implement small JSEngine to decipher stream signatures.
- Add channel thumbnails in search results. Thanks to BinaryQuantumSoul. #289
Expand Down
5 changes: 3 additions & 2 deletions lib/src/retry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import 'package:logging/logging.dart';

import '../youtube_explode_dart.dart';

final _logger = Logger('YoutubeExplode.Retry');

/// Run the [function] each time an exception is thrown until the retryCount
/// is 0.
Future<T> retry<T>(
YoutubeHttpClient? client,
FutureOr<T> Function() function,
) async {
final logger = Logger('YoutubeExplode.Retry');
var retryCount = 5;

// ignore: literal_only_boolean_expressions
Expand All @@ -22,7 +23,7 @@ Future<T> retry<T>(
if (client != null && client.closed) {
throw HttpClientClosedException();
}
logger.warning('Retrying after exception: $e', e, s);
_logger.warning('Retrying after exception: $e', e, s);
retryCount -= getExceptionCost(e);
if (retryCount <= 0) {
rethrow;
Expand Down
13 changes: 7 additions & 6 deletions lib/src/reverse_engineering/heuristics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import '../videos/streams/models/video_quality.dart';
import '../videos/streams/models/video_resolution.dart';

const _resolutionMap = <VideoQuality, VideoResolution>{
VideoQuality.unknown: VideoResolution(-1, -1),
VideoQuality.low144: VideoResolution(256, 144),
VideoQuality.low240: VideoResolution(426, 240),
VideoQuality.medium360: VideoResolution(640, 360),
Expand All @@ -26,23 +27,23 @@ extension VideoQualityUtil on VideoQuality {
}
label = label.toLowerCase();

if (label.startsWith('240')) {
if (label.startsWith('240') || label == '426x240') {
return VideoQuality.low144;
}

if (label.startsWith('360')) {
if (label.startsWith('360') || label == '640x360') {
return VideoQuality.medium360;
}

if (label.startsWith('480')) {
if (label.startsWith('480') || label == '854x480') {
return VideoQuality.medium480;
}

if (label.startsWith('720')) {
if (label.startsWith('720') || label == '1280x720') {
return VideoQuality.high720;
}

if (label.startsWith('1080')) {
if (label.startsWith('1080') || label == '1920x1080') {
return VideoQuality.high1080;
}

Expand All @@ -66,7 +67,7 @@ extension VideoQualityUtil on VideoQuality {
return VideoQuality.high4320;
}

if (label.startsWith('144')) {
if (label.startsWith('144') || label == '256x144') {
return VideoQuality.low144;
}

Expand Down
202 changes: 202 additions & 0 deletions lib/src/reverse_engineering/hls_manifest.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:http_parser/src/media_type.dart';
import 'package:logging/logging.dart';

import 'models/stream_info_provider.dart';

typedef VideoInfo = ({String url, Map<String, String> params});
typedef SegmentInfo = ({String url, double duration});

@internal
class HlsManifest {
static final _logger = Logger('YoutubeExplode.HLSManifest');
final List<VideoInfo> videos;

const HlsManifest(this.videos);

/// [hlsFile] is the content of the HLS file with lines separated by '\n'
static HlsManifest parse(String hlsFile) {
final lines = hlsFile.trim().split('\n');
assert(lines[0] == '#EXTM3U');
assert(lines[1] == '#EXT-X-INDEPENDENT-SEGMENTS');
final videos = <VideoInfo>[];
final expr = RegExp('([^,]+)=("[^"]*"|[^,]*)');
for (var i = 2; i < lines.length; i += 1) {
final line = lines[i];
final params = {
for (final match in expr.allMatches(line, line.indexOf(':') + 1))
match.group(1)!: match.group(2)!,
};
if (line.startsWith('#EXT-X-MEDIA:')) {
final url = params['URI']!;
// Trim the quotes
videos.add((url: url.substring(1, url.length - 1), params: params));
continue;
}
if (line.startsWith('#EXT-X-STREAM-INF:')) {
final url = lines[i + 1];
videos.add((url: url, params: params));
i++;
continue;
}
_logger.warning('Unknown HLS line: $line');
}
return HlsManifest(videos);
}

List<_StreamInfo> get streams {
final streams = <_StreamInfo>[];
for (final video in videos) {
// The tag is the number after the /itag/ segment in the url
final videoParts = video.url.split('/');
final itag = int.parse(videoParts[videoParts.indexOf('itag') + 1]);
// To find the file size look for the segments after the sgoap and sgovp parameters (audio + video)
// Then URL decode the value and find the clen= parameter
String? sgoap;
String? sgovp;
final sgoapIndex = videoParts.indexOf('sgoap');
final sgovpIndex = videoParts.indexOf('sgovp');
if (sgoapIndex != -1) {
sgoap = Uri.decodeFull(videoParts[sgoapIndex + 1]);
}
if (sgovpIndex != -1) {
sgovp = Uri.decodeFull(videoParts[sgovpIndex + 1]);
}

int? audioClen;
int? videoClen;
if (sgoap != null) {
audioClen =
int.parse(RegExp(r'clen=(\d+)').firstMatch(sgoap)!.group(1)!);
}
if (sgovp != null) {
videoClen =
int.parse(RegExp(r'clen=(\d+)').firstMatch(sgovp)!.group(1)!);
}

final bandwidth = int.tryParse(video.params['BANDWIDTH'] ?? '');
final codecs = video.params['CODECS']?.replaceAll('"', '').split(',');
final audioCodec = codecs?.first;
final videoCodec = codecs?.last;
final resolution = video.params['RESOLUTION']?.split('x');
final videoWidth = int.tryParse(resolution?[0] ?? '');
final videoHeight = int.tryParse(resolution?[1] ?? '');
final framerate = int.tryParse(video.params['FRAME-RATE'] ?? '');

streams.add(
_StreamInfo(
itag,
video.url,
audioCodec,
videoCodec,
resolution != null ? '${videoWidth}x$videoHeight' : null,
videoWidth,
videoHeight,
framerate,
(audioClen ?? 0) + (videoClen ?? 0),
bandwidth ?? 0,
videoClen == null,
audioClen == null,
),
);
}
return streams;
}

static List<SegmentInfo> parseVideoSegments(String hlsFile) {
final lines = hlsFile.trim().split('\n');
assert(lines[0] == '#EXTM3U');
assert(lines[1] == '#EXT-X-VERSION:3');
assert(lines[2] == '#EXT-X-PLAYLIST-TYPE:VOD');
assert(lines[3].startsWith('#EXT-X-TARGETDURATION:'));
final segments = <SegmentInfo>[];
for (var i = 4; i < lines.length; i += 2) {
if (lines[i] == '#EXT-X-ENDLIST') {
break;
}
final duration = double.parse(
lines[i].substring('#EXTINF:'.length, lines[i].length - 1));
final url = lines[i + 1];
segments.add((url: url, duration: duration));
}
return segments;
}
}

/*
BANDWIDTH=202508,CODECS="mp4a.40.5,avc1.4d400c",RESOLUTION=256x144,FRAME-RATE=30,VIDEO-RANGE=SDR,CLOSED-CAPTIONS=NONE
The hls stream provide the following information:
- BANDWIDTH: bytes
- CODECS: comma separated list of codecs
- RESOLUTION: width x height
- FRAME-RATE: frames per second
from the url:
- itag
*/
class _StreamInfo extends StreamInfoProvider {
@override
final int tag;

@override
final String url;

@override
String get container => 'm3u8';

@override
final String? audioCodec;

@override
final String? videoCodec;

@override
final MediaType codec;

@override
final String? qualityLabel;

@override
final int? videoWidth;

@override
final int? videoHeight;

@override
final int? framerate;

@override
final int contentLength;

@override
final int bitrate;

@override
final bool audioOnly;

@override
final bool videoOnly;

_StreamInfo(
this.tag,
this.url,
this.audioCodec,
this.videoCodec,
this.qualityLabel,
this.videoWidth,
this.videoHeight,
this.framerate,
this.contentLength,
this.bitrate,
this.audioOnly,
this.videoOnly,
) : codec = MediaType('application', 'vnd.apple.mpegurl', {
'codecs': [
if (audioCodec != null) audioCodec,
if (videoCodec != null) videoCodec,
].join(',')
});

@override
StreamSource get source => StreamSource.hls;
}
8 changes: 6 additions & 2 deletions lib/src/reverse_engineering/models/stream_info_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'package:http_parser/http_parser.dart';
import '../../videos/streams/models/audio_track.dart';
import 'fragment.dart';

enum StreamSource { muxed, adaptive, dash }
enum StreamSource { muxed, adaptive, dash, hls }

///
abstract class StreamInfoProvider {
Expand Down Expand Up @@ -47,7 +47,7 @@ abstract class StreamInfoProvider {
String? get videoQualityLabel => null;

///
String get qualityLabel;
String? get qualityLabel;

///
int? get videoWidth => null;
Expand All @@ -63,4 +63,8 @@ abstract class StreamInfoProvider {

///
AudioTrack? get audioTrack => null;

bool get audioOnly => false;

bool get videoOnly => false;
}
5 changes: 1 addition & 4 deletions lib/src/reverse_engineering/player/player_response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ class PlayerResponse {
.get('streamingData')
?.getList('formats')
?.map((e) => _StreamInfo(e, StreamSource.muxed))
.cast<StreamInfoProvider>()
.toList() ??
const <StreamInfoProvider>[];

Expand All @@ -128,7 +127,6 @@ class PlayerResponse {
.get('streamingData')
?.getList('adaptiveFormats')
?.map((e) => _StreamInfo(e, StreamSource.adaptive))
.cast<StreamInfoProvider>()
.toList() ??
const [];

Expand Down Expand Up @@ -233,8 +231,7 @@ class _StreamInfo extends StreamInfoProvider {
String get videoQualityLabel => qualityLabel;

@override
late final String qualityLabel = root.getT<String>('qualityLabel') ??
'tiny'; // Not sure if 'tiny' is the correct placeholder.
late final String qualityLabel = root.getT<String>('qualityLabel') ?? '';

@override
late final int? videoWidth = root.getT<int>('width');
Expand Down
19 changes: 18 additions & 1 deletion lib/src/reverse_engineering/youtube_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import 'package:logging/logging.dart';
import '../exceptions/exceptions.dart';
import '../extensions/helpers_extension.dart';
import '../retry.dart';
import '../videos/streams/mixins/hls_stream_info.dart';
import '../videos/streams/streams.dart';
import 'hls_manifest.dart';

/// HttpClient wrapper for YouTube
class YoutubeHttpClient extends http.BaseClient {
final http.Client _httpClient;
final _logger = Logger('YoutubeExplode.HttpClient');
static final _logger = Logger('YoutubeExplode.HttpClient');

// Flag to interrupt receiving stream.
bool _closed = false;
Expand Down Expand Up @@ -151,6 +153,11 @@ class YoutubeHttpClient extends http.BaseClient {
errorCount: errorCount,
);
}
if (streamInfo is HlsStreamInfo) {
return _getHlsStream(
streamInfo
);
}
// Normal stream
return _getStream(
streamInfo,
Expand Down Expand Up @@ -314,6 +321,16 @@ class YoutubeHttpClient extends http.BaseClient {
});
}

Stream<List<int>> _getHlsStream(HlsStreamInfo stream) async* {
final videoIndex = await getString(stream.url);
final video = HlsManifest.parseVideoSegments(videoIndex);
for (final segment in video) {
final data = await get(Uri.parse(segment.url));
yield data.bodyBytes;
}
}


@override
void close() {
_closed = true;
Expand Down
6 changes: 6 additions & 0 deletions lib/src/videos/streams/mixins/hls_stream_info.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

import '../../../../youtube_explode_dart.dart';

mixin HlsStreamInfo on StreamInfo {
int? get audioItag => null;
}
Loading

0 comments on commit 322bc97

Please sign in to comment.