PSLive-DL.psm1
#Requires -Version 5 <# .SYNOPSIS Initiates a watchdog to monitor the desired channel. .DESCRIPTION Initiates a watchdog service to automatically detect whenever the desired channel goes live. When the channel is live, the function will automatically begin recording the stream; when the channel has stopped streaming, the function will return to its monitoring state, thus repeating the cycle. .PARAMETER Url The channel to monitor (e.g., "twitch.tv/DarkViperAU", "https://www.youtube.com/c/dhctv", "https://www.youtube.com/watch?v=I2PF1SCi9qY") .PARAMETER Interval The interval in seconds to repeat the monitoring process. .PARAMETER Format The format to record the stream in. .PARAMETER CookieJar The location to a cookies.txt file. This is required if the channel is locked behind a member pay-wall and that your account has access to said channel. The cookies.txt file must comply to the specs listed here: https://docs.funnelback.com/collections/collection-types/web/web-crawler-settings/cookies_txt.html. The cookies.txt must also each be delimited by a tab character ("\t"). Firefox users can use the extension here to easily generate a cookies.txt for use of this module: https://addons.mozilla.org/ja/firefox/addon/cookies-txt/ .OUTPUTS None #> function Invoke-PSLiveWatchdog { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Url, [int] $Interval = 60, [ValidateSet('mkv', 'mp4')] [string] $Format = 'mp4', [string] $CookieJar ) begin { $activity = "Watching for stream $url online status..." } process { $i = 0 while ($true) { while (!(Get-StreamAvailability -Url $Url -CookieJar $CookieJar).IsOnline) { if ($i -ge 100) { $i = 0 } $i++ Write-Progress -Activity $activity -Status "Press CTRL+C to exit the watchdog." -PercentComplete $i Start-Sleep -Seconds $Interval } New-PSLiveRecording -Url $Url -Format $Format -SkipCheck -CookieJar $CookieJar } } end { } } function Update-EnvVars { $env:Path = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::Machine) + ";" + [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User) } function Install-PSLiveDependencies { [CmdletBinding()] param ( ) begin { $installSL = Get-Streamlink -ErrorAction SilentlyContinue $installFFMPEG = Get-FFMpeg -ErrorAction SilentlyContinue $installSL = [string]::IsNullOrEmpty($installSL) Write-Verbose "Install streamlink: $installSL" $installFFMPEG = [string]::IsNullOrEmpty($installFFMPEG) Write-Verbose "Install ffmpeg: $installFFMPEG" } process { if (-not ($installSL -or $installFFMPEG)) { Write-Host "Dependencies met. No installation required." return } $activity = "Installing dependencies..." if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { if ($installFFMPEG) { Write-Progress -Activity $activity -Status "Installing ffmpeg..." -PercentComplete 45 if ($installSL) { $title = 'Install ffmpeg?' $message = 'ffmpeg is available as an optional install in the streamlink installer. If you do not plan on using ffmpeg outside of streamlink, you may skip this dependency.' $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", 'Yes' $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No", 'No' $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no) $ffmpegPrompt = $host.ui.PromptForChoice($title, $message, $options, 0) } if ($null -eq $ffmpegPrompt -or $ffmpegPrompt -eq 0) { Deploy-FFMpeg } else { Write-Warning "ffmpeg installation aborted." } } if ($installSL) { Write-Progress -Activity $activity -Status "Installing streamlink..." -PercentComplete 90 Deploy-Streamlink } } else { Write-Error "Automatic dependency installation is only supported on Windows for now. Please install ffmpeg and streamlink separetely!" } Write-Progress -Activity $activity -Completed } end { Write-Host "Dependency setup complete!" -ForegroundColor Green } } function New-PSLiveBin { $targetPath = "$env:USERPROFILE\.pslive\bin" if ($targetPath) { return (Get-Item $targetPath) } $binPath = New-Item -ItemType Directory -Path $targetPath -Force $userEnvVars = [System.Environment]::GetEnvironmentVariable("PATH", [System.EnvironmentVariableTarget]::User) if ($userEnvVars -notcontains $binPath.FullName) { [System.Environment]::SetEnvironmentVariable("PATH", $userEnvVars + ";$binPath", [System.EnvironmentVariableTarget]::User) Update-EnvVars } return $binPath } function Deploy-Streamlink { $iwr = Invoke-WebRequest "https://api.github.com/repos/streamlink/streamlink/releases" -UseBasicParsing if ($iwr.StatusCode -eq 200) { $response = (($iwr).Content | ConvertFrom-Json) | Select-Object -First 1 | Select-Object -exp assets | Where-Object { $_.Name -match ".*\.exe" } if (($response | Measure-Object).Count -eq 1) { $fileName = [System.IO.Path]::GetFileName($response.browser_download_url) $slTempFile = Join-Path ([System.IO.Path]::GetTempPath()) $fileName Invoke-WebRequest -Uri $response.browser_download_url -OutFile $slTempFile -UseBasicParsing Start-Process $slTempFile -Wait Update-EnvVars Write-Host ("Installed " + (. streamlink --version) + "!") -ForegroundColor Green } else { throw [System.InvalidOperationException]::new("Failed to obtain the URL for streamlink.") } } else { throw [System.Net.WebException]::new("GitHub cannot be reached at the moment.") } } function Deploy-FFMpeg { $iwr = Invoke-WebRequest "https://api.github.com/repos/btbn/ffmpeg-builds/releases" -UseBasicParsing $binPath = New-PSLiveBin if ($iwr.StatusCode -eq 200) { $response = (($iwr).Content | ConvertFrom-Json) | Select-Object -First 1 | Select-Object -exp assets | Where-Object { $_.Name -match "n[\d]\.[\d]\.[\d]-.*-gpl.*shared.*\.zip" } if (($response | Measure-Object).Count -eq 1) { $ffmpegTempFile = Join-Path ([System.IO.Path]::GetTempPath()) "ffmpeg-temp.zip" Invoke-WebRequest -Uri $response.browser_download_url -OutFile $ffmpegTempFile -UseBasicParsing Expand-Archive $ffmpegTempFile -DestinationPath $binPath -Force Get-ChildItem -Path $binPath -Recurse -Filter "bin" | Select-Object -first 1 | Get-ChildItem | ForEach-Object { move-item -Path $_.FullName -Destination $binPath } $version = ((. (Join-Path $binPath "ffprobe.exe") -v 0 -of json -show_program_version) | convertfrom-json).program_version.version Write-Host "Installed ffmpeg ($version) to $binPath!" -ForegroundColor Green } else { throw [System.InvalidOperationException]::new("Failed to obtain the URL for nightly ffmpeg build.") } } else { throw [System.Net.WebException]::new("GitHub cannot be reached at the moment.") } } function New-PSLiveRecording { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Url, [ValidateSet('mkv', 'mp4')] [string] $Format = 'mp4', [switch] $SkipCheck, [string] $CookieJar ) begin { if (!$SkipCheck) { $activity = "Checking if the channel is live..." Write-Progress -Activity $activity -PercentComplete 25 $streamAvailable = Get-StreamAvailability -Url $Url -CookieJar $CookieJar Write-Progress -Activity $activity -Completed if ($null -ne $streamAvailable.ExternalError) { throw [System.InvalidOperationException]::new($streamAvailable.ExternalError) } if (-not $streamAvailable.IsOnline) { throw [System.InvalidOperationException]::new("Stream is not online; $($streamAvailable.Error)") } } } process { $result = Invoke-Streamlink -Url $Url -Format $Format -CookieJar $CookieJar } end { if ($result.StandardOutput) { Write-Verbose $result.StandardOutput } } } function Get-StreamAvailability { [CmdletBinding()] param ( [string] $Url, [string] $CookieJar ) begin { $returnResponse = [PSCustomObject]@{ IsOnline = $false ExternalError = $null Error = $null Streams = $null } } process { $response = Invoke-Streamlink -Url $Url -Json -CookieJar $CookieJar if ($response.StandardError) { $returnResponse.ExternalError = $response.StandardError return $returnResponse } $result = $response.StandardOutput | ConvertFrom-Json if ($result.error) { $returnResponse.Error = $result.error } else { $returnResponse.IsOnline = $true if ($result.streams) { $returnResponse.Streams = $result | Select-Object -exp Streams } } Write-Verbose $result } end { return $returnResponse } } function Repair-Filename($Filename) { return $Filename.Split([System.IO.Path]::GetInvalidFileNameChars()) -join '-' } function Invoke-Streamlink { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Url, [switch] $Json, [Parameter(ParameterSetName = "Record")] [string] $OutputDirectory = "$env:USERPROFILE\.pslive\", [Parameter(ParameterSetName = "Record")] [string] $OutputName, [Parameter(ParameterSetName = "Record")] [ValidateSet('mkv', 'mp4')] [string] $Format = "mp4", [string] $CookieJar ) begin { $sl = Get-Streamlink if ($null -eq $sl) { throw [System.IO.FileNotFoundException]::new("Streamlink not found. Please configure the required dependencies via Install-PSLiveDependencies.") } $OutputName = [System.IO.Path]::GetFileName($OutputName) if (!(Test-Path $OutputDirectory)) { $OutputDirectory = (New-Item -Path $OutputDirectory -ItemType Directory -Force).FullName } } process { $slArgs += Get-CommonArgs $slArgs += " --url $Url" if ($Json) { $slArgs += " --json" $psi = [System.Diagnostics.ProcessStartInfo]::new($sl) $psi.Arguments = $slArgs $psi.UseShellExecute = $false $psi.RedirectStandardError = $true $psi.RedirectStandardOutput = $true $p = [System.Diagnostics.Process]::new() $p.StartInfo = $psi $p.Start() > $null $stdout = $p.StandardOutput.ReadToEnd() $stderr = $p.StandardError.ReadToEnd() $p.WaitForExit() } else { # prepare output file if (-not $OutputName) { $OutputName = [System.DateTimeOffset]::Now.ToString("yyyy-MM-dd_HH.mm.ss-") + [System.IO.Path]::GetFileName($Url) } $OutputName = Repair-Filename $OutputName if ([string]::IsNullOrEmpty($OutputName)) { throw [InvalidOperationException]::new("Stream filename cannot be null or empty.") } $OutputName = "$OutputName.$Format" $outputPath = Join-Path $OutputDirectory $OutputName $slArgs += " --output `"$outputPath`"" if ($Format) { $slArgs += " --ffmpeg-fout $Format" } # prepare CookieJar if (!([string]::IsNullOrEmpty($CookieJar))) { $cookieArgs = Convert-CookieJarToArgs $CookieJar $slArgs += " $($cookieArgs)" } $activity = "Recording $url since $([DateTime]::Now)..." Write-Progress -Activity $activity -Status "Close the newly created (minimized) Streamlink window to stop recording." -PercentComplete 45 # A bit of a hack: # Spawn a new window on purpose, so the stream output can be properly terminated by the user # While we could capture CTRL+C, it is not wise to do so for long-running tasks, as we still have a remux job afterwards. Start-Process $sl -ArgumentList $slArgs -Wait -WindowStyle Minimized # Post-recording if (!(Test-Path $outputPath)) { throw [System.IO.FileNotFoundException]::new("Streamlink failed to create an expected output.") } $baseName = [System.IO.Path]::GetFileNameWithoutExtension($OutputName) $remuxedOutputPath = Join-Path $OutputDirectory "$baseName-final.$Format" Write-Progress -Activity $activity -Status "Remuxing..." -PercentComplete 90 Start-Process -FilePath (Get-FFMpeg) -ArgumentList "-i", "`"$outputPath`"", "-c", "copy", "`"$remuxedOutputPath`"" -Wait -NoNewWindow Remove-Item $outputPath -Force Write-Host "Finished capture!" -ForegroundColor Green } } end { Write-Progress -Activity $activity -Completed return [PSCustomObject]@{ StandardOutput = $stdout StandardError = $stderr } } } function Convert-CookieJarToArgs($Path) { if (-not (Test-Path $Path)) { throw [FileNotFoundException]::new("$Path cannot be found; unable to parse the specified cookie jar.") } $cookieJarContent = Get-Content $Path -Raw $cookieJarContent = [regex]::Replace($cookieJarContent, '^#.*$', '', [System.Text.RegularExpressions.RegexOptions]::Multiline) $cookieJarContent = ("Domain`tTailmatch`tPath`tSecure`tExpires`tName`tValue`n" + $cookieJarContent) | ConvertFrom-Csv -Delimiter "`t" return ($cookieJarContent | ForEach-Object { "--http-cookie " + "`"" + $_.name + "=" + $_.value + "`"" }) -join " " } function Get-CommonArgs { $ffmpeg = Get-FFMpeg if ($null -eq $ffmpeg) { throw [System.IO.FileNotFoundException]::new("ffmpeg not found. Please configure the required dependencies via Install-PSLiveDependencies.") } return "--ffmpeg-ffmpeg", "`"$ffmpeg`"", "--http-timeout", 5, "--stream-timeout", 5, "--http-stream-timeout", 5, "--default-stream", "best", "--force" } function Get-FFMpeg { $sl = Get-Command ffmpeg -ErrorAction SilentlyContinue -CommandType Application | Select-Object -First 1 if ($null -eq $sl) { if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { $installReg = Get-ChildItem Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\ | Get-ItemProperty | Where-Object { $_.DisplayName -match "streamlink" } if ($installReg.InstallLocation) { $src = get-childitem -path $installreg.InstallLocation -filter ffmpeg.exe -recurse if ($src) { return $src.FullName } } } return $null } else { return $sl.Source } } function Get-Streamlink { $sl = Get-Command streamlink -ErrorAction SilentlyContinue -CommandType Application | Select-Object -First 1 if ($null -eq $sl) { return $null } else { return $sl.Source } } |