OPENREC.tv live stream downloader; needs PowerShell 7.5+, yt-dlp (to extract the playlist URL), ffmpeg
openrec-dl.ps1 edited
293 lines 10 kB view raw
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