-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathIYouTubeVideoClient.cs
More file actions
207 lines (178 loc) · 10.5 KB
/
Copy pathIYouTubeVideoClient.cs
File metadata and controls
207 lines (178 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
using Cysharp.Threading.Tasks;
using DCL.Diagnostics;
using DCL.SDKComponents.MediaStream.YouTube;
using DCL.Utilities.Extensions;
using DCL.Utility.Types;
using DCL.WebRequests;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using UnityEngine;
using UnityEngine.Pool;
namespace DCL.SDKComponents.MediaStream
{
internal interface IYouTubeVideoClient
{
/// <summary>Returns true if the video is a live stream.</summary>
UniTask<bool> IsLiveStreamAsync(VideoId videoId, CancellationToken ct);
UniTask<StreamManifest> GetStreamManifestAsync(VideoId videoId, CancellationToken ct);
/// <summary>
/// Returns a streaming manifest URL for the given video. Resolution order:
/// 1. YouTube's native HLS manifest (if returned)
/// 2. YouTube's native DASH manifest (if returned)
/// 3. For VODs only — a locally synthesized HLS multivariant playlist written to
/// <see cref="Application.temporaryCachePath"/> and exposed as a <c>file://</c>
/// URL. Fixes A/V sync on embed-restricted videos that only get muxed itag=18.
/// HLS chosen over DASH because every AVPro backend (AVFoundation/MediaFoundation/
/// ExoPlayer) supports it natively; DASH support varies by platform.
/// 4. Empty string — caller falls through to muxed-MP4 selection.
/// </summary>
UniTask<string> GetStreamingManifestUrlAsync(VideoId videoId, CancellationToken ct);
}
internal class YouTubeVideoClient : IYouTubeVideoClient
{
private const string TAG = nameof(YouTubeVideoClient);
private const string SYNTH_HLS_DIR_PREFIX = "youtube_hls_";
private const string MASTER_PLAYLIST_NAME = "master.m3u8";
private const string VIDEO_PLAYLIST_NAME = "video.m3u8";
private const string AUDIO_PLAYLIST_NAME = "audio.m3u8";
// HLS spec requires UTF-8 without BOM (RFC 8216 §4).
private static readonly UTF8Encoding HLS_ENCODING = new (encoderShouldEmitUTF8Identifier: false);
private readonly InnerTubeClient innerTube = new ();
private readonly IWebRequestController webRequestController;
public YouTubeVideoClient(IWebRequestController webRequestController)
{
this.webRequestController = webRequestController;
}
public async UniTask<bool> IsLiveStreamAsync(VideoId videoId, CancellationToken ct)
{
PlayerResponse response = await innerTube.FetchPlayerResponseAsync(videoId, ct);
return response.IsLive;
}
public async UniTask<StreamManifest> GetStreamManifestAsync(VideoId videoId, CancellationToken ct)
{
PlayerResponse response = await innerTube.FetchPlayerResponseAsync(videoId, ct);
return new StreamManifest(response.MuxedStreams, response.VideoOnlyStreams);
}
public async UniTask<string> GetStreamingManifestUrlAsync(VideoId videoId, CancellationToken ct)
{
PlayerResponse response = await innerTube.FetchPlayerResponseAsync(videoId, ct);
// Native HLS — preferred (rock-solid AVPro support across all platforms).
if (!string.IsNullOrEmpty(response.HlsManifestUrl))
return response.HlsManifestUrl!;
// Native DASH — works on AVPro Pro on Windows/Android, limited on macOS.
if (!string.IsNullOrEmpty(response.DashManifestUrl))
return response.DashManifestUrl!;
// Synthesized HLS fallback — only for VODs. Live streams without HLS are unplayable
// here (they'd need a different live format) so we don't synthesize for them.
if (!response.IsLive && response.AdaptiveFormats.Count > 0)
{
string? synthesizedPath = await TryWriteSynthesizedHlsAsync(videoId, response, ct);
if (!string.IsNullOrEmpty(synthesizedPath))
{
ReportHub.Log(ReportCategory.MEDIA_STREAM,
$"[{TAG}] Synthesized HLS playlist for {videoId.Value} at {synthesizedPath}");
return "file://" + synthesizedPath;
}
}
return string.Empty;
}
/// <summary>
/// Generates an HLS multivariant playlist (master + video + audio) from the response's
/// adaptive streams, writes the 3 files into a per-video subdirectory of the temp
/// cache, and returns the absolute path of the master playlist. Returns null on any
/// failure (no usable streams, write error, etc.) so the caller falls through to the
/// muxed path.
///
/// Pre-fetches the sidx (segment index) box for the selected video and audio streams
/// via byte-range HTTP requests; that lets the playlist enumerate one HLS segment per
/// fmp4 fragment instead of a single segment over the entire file. AVPro can then
/// start playback after fetching the first ~5-10s chunk rather than the whole body
/// (issue #8350). If sidx fetch or parse fails, falls back to single-segment.
///
/// Deliberately contains no try/catch/using around its await: an exception unwinding
/// through this async state machine's catch and finally funclets crashes IL2CPP on
/// Windows inside catch-clause matching (Sentry UNITY-EXPLORER-P77). The fetch is
/// therefore exception-free by contract and everything that can throw lives in the
/// synchronous <see cref="WriteSynthesizedHls"/>.
/// </summary>
private async UniTask<string?> TryWriteSynthesizedHlsAsync(VideoId videoId, PlayerResponse response, CancellationToken ct)
{
if (response.DurationSeconds <= 0
|| !HlsManifestBuilder.TrySelectVideoAndAudio(response.AdaptiveFormats, out AdaptiveFormatData videoStream, out AdaptiveFormatData audioStream))
{
ReportHub.Log(ReportCategory.MEDIA_STREAM,
$"[{TAG}] HLS synthesis skipped for {videoId.Value} — no usable mp4 video+audio adaptive pair");
return null;
}
// Fetch both sidx boxes in parallel. Each is typically a few KB.
(Result<byte[]> videoSidx, Result<byte[]> audioSidx) = await UniTask.WhenAll(
FetchByteRangeAsync(videoStream.Url, videoStream.IndexRangeStart, videoStream.IndexRangeEnd, ct),
FetchByteRangeAsync(audioStream.Url, audioStream.IndexRangeStart, audioStream.IndexRangeEnd, ct));
if (ct.IsCancellationRequested)
return null;
return WriteSynthesizedHls(videoId, videoStream, audioStream, response.DurationSeconds, videoSidx, audioSidx);
}
/// <summary>
/// Fetches an inclusive byte range from <paramref name="url"/> via the project's
/// <see cref="IWebRequestController"/>, using the built-in <c>Range</c> header support.
/// Never throws — failures and cancellation both surface as an unsuccessful
/// <see cref="Result{T}" /> so no exception crosses the <c>UniTask.WhenAll</c> boundary.
/// </summary>
private UniTask<Result<byte[]>> FetchByteRangeAsync(string url, long start, long endInclusive, CancellationToken ct) =>
webRequestController
.GetAsync(url, ct, ReportCategory.MEDIA_STREAM,
headersInfo: new WebRequestHeadersInfo().WithRange(start, endInclusive),
suppressErrors: true)
.GetDataCopyAsync()
// Fixes: https://github.com/decentraland/unity-explorer/issues/9758
.SuppressToResultAsync();
/// <summary>
/// Parses the pre-fetched sidx boxes, builds the 3 playlists and writes them to the
/// temp cache, returning the absolute path of the master playlist. Returns null if
/// any step fails. Synchronous on purpose — see <see cref="TryWriteSynthesizedHlsAsync"/>.
/// </summary>
private static string? WriteSynthesizedHls(
VideoId videoId,
AdaptiveFormatData videoStream,
AdaptiveFormatData audioStream,
int durationSeconds,
Result<byte[]> videoSidx,
Result<byte[]> audioSidx)
{
try
{
using var _ = ListPool<SidxParser.SegmentInfo>.Get(out var videoSegments);
using var __ = ListPool<SidxParser.SegmentInfo>.Get(out var audioSegments);
if (videoSidx.Success)
SidxParser.TryParse(videoSidx.Value, videoStream.IndexRangeEnd + 1, videoSegments);
else
ReportHub.Log(ReportCategory.MEDIA_STREAM, $"[{TAG}] video sidx byte-range fetch failed: {videoSidx.ErrorMessage}");
if (audioSidx.Success)
SidxParser.TryParse(audioSidx.Value, audioStream.IndexRangeEnd + 1, audioSegments);
else
ReportHub.Log(ReportCategory.MEDIA_STREAM, $"[{TAG}] audio sidx byte-range fetch failed: {audioSidx.ErrorMessage}");
HlsManifestBuilder.PlaylistSet playlists =
HlsManifestBuilder.Build(videoStream, audioStream, durationSeconds, videoSegments, audioSegments);
// Per-video subdirectory keeps the 3 files together so the master playlist's
// relative URIs (audio.m3u8, video.m3u8) resolve correctly. Unity's
// temporaryCachePath is OS-cleaned so we don't need to garbage-collect.
string playlistDir = Path.Combine(Application.temporaryCachePath, SYNTH_HLS_DIR_PREFIX + videoId.Value);
Directory.CreateDirectory(playlistDir);
File.WriteAllText(Path.Combine(playlistDir, VIDEO_PLAYLIST_NAME), playlists.Video, HLS_ENCODING);
File.WriteAllText(Path.Combine(playlistDir, AUDIO_PLAYLIST_NAME), playlists.Audio, HLS_ENCODING);
string masterPath = Path.Combine(playlistDir, MASTER_PLAYLIST_NAME);
File.WriteAllText(masterPath, playlists.Master, HLS_ENCODING);
return masterPath;
}
catch (Exception ex)
{
ReportHub.LogWarning(ReportCategory.MEDIA_STREAM,
$"[{TAG}] HLS synthesis failed for {videoId.Value}: {ex.Message}");
return null;
}
}
}
}