OPENREC.tv live stream downloader; needs PowerShell 7.5+, yt-dlp (to extract the playlist URL), ffmpeg
openrec-dl.ps1
edited
1param(
2 $Url,
3 $CookiesFromBrowser = "firefox"
4)
5
6while (1) {
7 <# get format info #>
8 while (1) {
9 Write-Host "Checking if stream is live..."
10 $x = yt-dlp --cookies-from-browser $CookiesFromBrowser -F $Url
11 if ($LASTEXITCODE -eq 0) {
12 write-warning "Live!"
13 break;
14 }
15 Write-Warning "Not live, waiting 5 minutes >> $ww"
16 sleep (60*5)
17 }
18
19 Write-Host "Ripping formats with yt-dlp..."
20 $InfoJson = yt-dlp --cookies-from-browser $CookiesFromBrowser --dump-json $Url | ConvertFrom-Json
21 $VideoId = $InfoJson.id
22 $DvrVideoFmt = $InfoJson.formats | ? { $_.format_id -ilike "*dvr*" } | select-object -last 1
23 $DvrAudioFmts = $InfoJson.formats | ? { $_.format_id -ilike "*dvr*audio*" }
24
25 # TODO: DVR video format may sometimes also carry audio with it, take into account vvv
26
27 if (( $DvrAudioFmts | ? { $_.format_id -ilike '*bypass*' } )) {
28 $DvrAudioFmt = $DvrAudioFmts | ? { $_.format_id -ilike '*bypass*' } | select-object -last 1
29 } else {
30 $DvrAudioFmt = $DvrAudioFmts | select-object -last 1
31 Write-Warning "DVR bypass audio format not available. Using potentially lower quality $($DvrAudioFmt.format_id) format!"
32 }
33
34 if ($null -eq $DvrVideoFmt) {
35 Write-Warning "The DVR video format isn't available yet/anymore. Cannot proceed!!! Is the stream live?"
36 exit 1
37 } elseif ($null -eq $DvrAudioFmt) {
38 Write-Warning "The DVR video format isn't available yet/anymore. Cannot proceed!!! Is the stream live?"
39 exit 1
40 }
41
42 break;
43}
44
45Write-Host "Selected formats (v)$($DvrVideoFmt.format_id) + (a)$($DvrAudioFmt.format_id)"
46
47<# ### ### ### #>
48
49$Headers = @{
50 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
51 'Accept' = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
52 'Accept-Language' = 'en-us,en;q=0.5'
53 'Sec-Fetch-Mode' = 'navigate'
54 'Referer' = 'https://www.openrec.tv/'
55}
56
57$BinFiles = (("v@"+$DvrVideoFmt.format_id), $DvrVideoFmt),(("a@"+$DvrAudioFmt.format_id), $DvrAudioFmt) | % -Throttle 2 -Parallel {
58 $Hs = $using:Headers
59
60 $existing = [System.Collections.Concurrent.ConcurrentBag[Int32]]::new()
61
62 $VideoId = $using:VideoId
63 $ThreadId = $_[0]
64 $Fmt = $_[1]
65 $Id = $Fmt.format_id
66 $Playlist = $Fmt.url
67 $LocalPlaylistFile = "$VideoId--$Id.m3u8"
68 $FragUrlBase = $Playlist -replace 'chunklist(_\d.*|\.m3u8)',''
69
70 function l([Switch]$D, [Switch]$W,[Switch]$E, $Message) {
71 if ($E) {
72 throw "[$ThreadId]: $Message"
73 } elseif ($W) {
74 Write-Host "[$ThreadId]: $Message" -Foreground Yellow
75 } elseif ($D) {
76 Write-Host "[$ThreadId]: $Message" -Foreground DarkGray
77 } else {
78 Write-Host "[$ThreadId]: $Message"
79 }
80 }
81
82 # like 0/seg_<some id?>_<FRAGNO>_<video|audio>_<timestamp?>_llhls.m4s
83 filter parse-fragmentname() {
84 if ($_ -like "*.m4s") {
85 $_ -match '(?<=0\/seg_\d_)\d+' | out-null
86 } else {
87 $_ -match '\d+(?=\.(?:ts|aac)$)' |out-null
88 }
89
90 $FragLinkName = $_ -split '/',2 | select-object -last 1
91 $raw = $matches[0]
92
93 return @{ Link=$_; Id=[int]::parse($Raw); Filename=$FragLinkName }
94 }
95
96 try {
97 l "reading $VideoId--$ThreadId.finished"
98 $PreexistingFragments = gc "$VideoId--$ThreadId.finished" -ea stop
99 l "found $( $PreexistingFragments.Count ) preexisting fragments, skipping them..."
100 $PreexistingFragments | % { $existing.Add( [int]::parse($_) ) }
101 } catch {
102 l "found no preexisting fragments"
103 # first run, dont worry
104 }
105
106 l "hello for list $Playlist"
107
108 new-item -type dir -name "$VideoId--$Id" -force | out-null
109
110 $StreamHasEnded = $false
111 #$StreamHasEnded = $true # true for testing
112
113 <# initial playlist download #>
114 try {
115 if (-not $StreamHasEnded) {
116 iwr -Headers $Hs $Playlist -Outfile $LocalPlaylistFile -SessionVar Session
117 } else {
118 # testing as mentioned still needs $session which is also now made here
119 iwr -Headers $Hs $using:Url -SessionVar Session | Out-Null
120 }
121 } catch {
122 l -E "Failed to get playlist $Playlist. Cannot proceed!"
123 }
124
125 <# SOME playlists are m4s, some are aac+ts #>
126 $Filetype = (gc $LocalPlaylistFile | select-object -first 20 | ? { $_ -notmatch '^#' } | select-object -first 1) -split '\.' | select-object -last 1
127 if ($Filetype -eq 'm4s') {
128 <# get m4s init fragment #>
129 $InitFrag = (gc $LocalPlaylistFile | select-object -first 20 | ? { $_ -like '#EXT-X-MAP:URI=*' } ) -split '=',2 -replace '^.|.$','' | select-object -last 1
130 $InitFragName = $InitFrag -replace '^0\/',''
131 l "Found M4S, init=$InitFragName"
132 try {
133 iwr -WebSession $Session "$FragUrlBase$InitFrag" -OutFile "$VideoId--$Id/a-$InitFragName"
134 } catch {
135 l -E "Failed to get init fragment, cannot proceed!!!"
136 }
137 } else {
138 # no init for aac+ts formats
139 l "Found $Filetype"
140 }
141
142 <# loop and download fragments #>
143 $NoNewFragmentRoundAttemptsRemaining = 10
144 while (1) {
145 if (-not $StreamHasEnded) {
146 <# if playlist fails, retry a few times in case of network issues #>
147 $refreshes = 10
148 while ( $refreshes -gt 0) {
149 try {
150 l -D "Refreshing playlist... ($refreshes attempts remaining)"
151 iwr -WebSession $Session $Playlist -Outfile $LocalPlaylistFile
152 break;
153 } catch {
154 $refreshes--
155 sleep 6
156 # no continue, fall through to next if() when <=0
157 }
158 }
159
160 if ($refreshes -le 0) {
161 l -W "Failed to refresh playlist 10 times, assuming stream has ended"
162 $StreamHasEnded = $true
163 continue;
164 }
165
166 if ( (gc $LocalPlaylistFile -Raw) -like "*#EXT-X-ENDLIST*") {
167 l -W "Found EXT-X-ENDLIST marker in playlist; stream has ended!"
168 $StreamHasEnded = $true
169 continue;
170 }
171 }
172
173 $AllFragments = get-content $LocalPlaylistFile | ? { $_ -notmatch '^#' }
174
175 <# filter out what we already have #>
176 $PendingFragments = $AllFragments | parse-fragmentname | ? { $_.Id -notin $existing }
177 # pending will unexpectedly be a hashtable instead of hashtable[] when the ? clause returns only one element
178 if ( $null -ne $PendingFragments -and $PendingFragments.GetType() -eq [Hashtable] ) {
179 $PendingFragments = @($PendingFragments)
180 }
181
182 $s = (2 + (get-random -min -1.0 -max 1.0))
183
184 <# check if playlist has stagnated, on 10 straight refreshes and stagnations (60s), break #>
185 if ($null -eq $PendingFragments) {
186 if (-not $StreamHasEnded) {
187 if ($NoNewFragmentRoundAttemptsRemaining -le 0) {
188 l -W "No new fragments found in the last 10 rounds! Assuming the stream has ended..."
189 $StreamHasEnded = $true
190 continue;
191 }
192
193 $NoNewFragmentRoundAttemptsRemaining--
194 l -W "No new fragments! $NoNewFragmentRoundAttemptsRemaining attempts remaining..."
195 l -D "playlist: sleeping $s"
196 sleep 6
197 continue;
198 } else {
199 l "No new fragments and the stream has been marked as over. Exiting thread, post-processing will continue after both threads exit."
200 break;
201 }
202 }
203
204 <# found some fragments... #>
205 l "Found $( $PendingFragments.Count ) new fragments: $( ($PendingFragments.Id) -join ',' )"
206 # ok this can actually be pretty small, the website hits its playlist like every 2 seconds
207 # smaller = refresh playlist sooner, less chance of missing some fragnents at the end
208 $FragmentAmount = 6
209
210 $PendingFragments = $PendingFragments | Select-Object -first $FragmentAmount
211 l "Selecting earliest $( [math]::min($FragmentAmount, $PendingFragments.Count) ) fragments to run: $( ($PendingFragments.Id) -join ',' )"
212
213 <# no new fragments? #>
214 if ($PendingFragments.Count -eq 0) {
215 if ($NoNewFragmentRoundAttemptsRemaining -le 0) {
216 l -W "No new fragments found in the last 10 rounds! Assuming the stream has ended..."
217 $StreamHasEnded = $true
218 continue;
219 }
220
221 $NoNewFragmentRoundAttemptsRemaining--
222 l -W "No new fragments! $NoNewFragmentRoundAttemptsRemaining attempts remaining"
223 l -D "playlist: sleeping $s"
224 sleep ($s*2)
225 continue;
226 }
227 $NoNewFragmentRoundAttemptsRemaining = 10
228
229 <# download new fragments #>
230 $PendingFragments | % -throttle ($FragmentAmount/2) -par {
231 $FragmentData = $_
232 $ThreadId = $using:ThreadId
233
234 $FragLink = $FragmentData.Link
235 $FragLinkName = $FragmentData.Filename
236 $FragmentID_Int = $FragmentData.Id
237 $FragmentID_Str = "{0:d5}" -f $FragmentID_Int
238
239 function l([Switch]$D, [Switch]$W,[Switch]$E, $Message) {
240 if ($E) {
241 throw "[$ThreadId]: $Message"
242 } elseif ($W) {
243 Write-Host "[$ThreadId]: $Message" -Foreground Yellow
244 } elseif ($D) {
245 Write-Host "[$ThreadId]: $Message" -Foreground DarkGray
246 } else {
247 Write-Host "[$ThreadId]: $Message"
248 }
249 }
250
251 l -D "Downloading fragment $FragmentID_Str : $using:FragUrlBase$FragLink"
252 iwr -WebSession $using:Session "$using:FragUrlBase$FragLink" -outfile "$using:VideoId--$using:Id/b-$FragmentID_Str.$using:Filetype"
253 ($using:existing).Add( $FragmentID_Int )
254 }
255
256 $existing -join "`n" | out-file -encoding utf8nobom -nonewline "$VideoId--$ThreadId.finished" -ea ignore
257
258 l -D "playlist: sleeping $s"
259 sleep $s
260 }
261
262 <# finished downloading!! post-process #>
263 l "Merging fragments..."
264 gci "$VideoId--$Id/*.$Filetype" | gc -AsByteStream -ReadCount 0 | set-content -AsByteStream "$VideoId--$Id/merged.bin"
265 return @{Filename="$VideoId--$Id/merged.bin"; MediaType=( $ThreadId[0] )}
266}
267
268$VideoOffset = 0.0
269$VideoFile = $null
270$AudioOffset = 0.0
271$AudioFile = $null
272# fix desync
273$BinFiles | % {
274 $I = $_
275 write-host ($I|out-default)
276
277 if ($_.MediaType -eq 'v') {
278 $VideoOffset = (ffprobe -v error -show_streams -show_format $I.Filename) | ? { $_ -like "start_time=*" } | Select-Object -first 1| % replace start_time= ''
279 $VideoFile = $_.Filename
280 } else {
281 $AudioOffset = (ffprobe -v error -show_streams -show_format $I.Filename) | ? { $_ -like "start_time=*" } | Select-Object -first 1| % replace start_time= ''
282 $AudioFile = $_.Filename
283 }
284}
285
286Write-Host "voff=$VideoOffset aoff=$AudioOffset calculated=$(-1 * ($VideoOffset - $AudioOffset))"
287ffmpeg -v error -y -itsoffset "$(-1 * ($VideoOffset - $AudioOffset))" -i $AudioFile -i $VideoFile -c copy "$VideoId.mp4"
288ffmpeg -v error -y -itsoffset "$($VideoOffset - $AudioOffset)" -i $AudioFile -i $VideoFile -c copy "$VideoId-poffset.mp4"
289gi "$VideoId.mp4","$VideoId-poffset.mp4"
290
291Write-Host "$VideoID.mp4 should be correct, but there is a small chance the audio offset is backwards. If audio is desynced, check $VideoId-poffset.mp4"
292
293exit