PSCurrent/Find-TwitchXRef.ps1

#.ExternalHelp StreamXRef-help.xml
function Find-TwitchXRef {
    [CmdletBinding()]
    [OutputType([System.String])]
    Param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Source,

        [Parameter(Mandatory = $true, Position = 1, ValueFromPipelineByPropertyName = $true)]
        [Alias("XRef")]
        [ValidateNotNullOrEmpty()]
        [string]$Target,

        [Parameter()]
        [ValidateRange(1, 100)]
        [int]$Count = 20,

        [Parameter()]
        [ValidateRange("NonNegative")]
        [int]$Offset = 0,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [Alias("en")]
        [switch]$ExplicitNull
    )

    DynamicParam {
        $mandAttr = [System.Management.Automation.ParameterAttribute]::new()
        if ([string]::IsNullOrWhiteSpace($script:TwitchData.ApiKey)) {
            $mandAttr.Mandatory = $true
        }
        else {
            $mandAttr.Mandatory = $false
        }
        $vnnoeAttr = [System.Management.Automation.ValidateNotNullOrEmptyAttribute]::new()
        $attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
        $attributeCollection.Add($mandAttr)
        $attributeCollection.Add($vnnoeAttr)

        $dynParam1 = [System.Management.Automation.RuntimeDefinedParameter]::new("ApiKey", [string], $attributeCollection)

        $paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
        $paramDictionary.Add("ApiKey", $dynParam1)
        return $paramDictionary
    }

    Begin {
        $API = "https://api.twitch.tv/kraken"
        $NewDataAdded = $false

        $VideoPattern = "(?:twitch\.tv/|^)v(?:ideos?)?/"

        if ($PSBoundParameters.ContainsKey("ApiKey")) {
            $ClientID = $PSBoundParameters.ApiKey

            if ($script:TwitchData.ApiKey -ine $PSBoundParameters.ApiKey) {
                $NewDataAdded = $true
            }

            $script:TwitchData.ApiKey = $PSBoundParameters.ApiKey
        }
        else {
            $ClientID = $script:TwitchData.ApiKey
        }

        $v5Headers = @{
            "Client-ID" = $ClientID
            "Accept"    = "application/vnd.twitchtv.v5+json"
        }

        # Temporary list for suppressing additional API calls when the username isn't found while processing a list/array of inputs
        $NotFoundList = [System.Collections.Generic.List[string]]::new()
    }

    Process {
        <# This trap is used for making only "404 Not Found" errors a non-terminating error
            because, for some reason, Twitch also uses that with some (but not all...) API
            endpoints to indicate that no results were found. #>

        trap [Microsoft.PowerShell.Commands.HttpResponseException] {
            # API Responded with error status
            if ($_.Exception.Response.StatusCode -eq 404) {
                # Not found
                $PSCmdlet.WriteError($_)
                if ($ExplicitNull) {
                    return $null
                }
                else {
                    return
                }
            }
            else {
                # Other error status codes
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }

        $RestArgs = @{
            Method      = "Get"
            Headers     = $v5Headers
            ErrorAction = "Stop"
        }

        # Initial basic sorting
        $SourceIsVideo = $Source -imatch $VideoPattern ? $true : $false
        $TargetIsVideo = $Target -imatch $VideoPattern ? $true : $false

        #region Source Lookup ##########################

        if ($SourceIsVideo) {
            # Video URL provided

            # Get offset from URL parameters or return if no match
            if ($Source -inotmatch "[?&]t=((?<Hours>\d+)h)?((?<Minutes>\d+)m)?((?<Seconds>\d+)s)?") {
                Write-Error "(Video) URL missing timestamp parameter." -ErrorId MissingTimestamp -Category InvalidArgument -CategoryTargetName Source -TargetObject $Source
                if ($ExplicitNull) {
                    return $null
                }
                else {
                    return
                }
            }

            $OffsetArgs = @{ }
            $OffsetArgs["Hours"] = $Matches.ContainsKey("Hours") ? $Matches.Hours : 0
            $OffsetArgs["Minutes"] = $Matches.ContainsKey("Minutes") ? $Matches.Minutes : 0
            $OffsetArgs["Seconds"] = $Matches.ContainsKey("Seconds") ? $Matches.Seconds : 0

            $TimeOffset = New-TimeSpan @OffsetArgs

            # Assuming that Twitch will switch to 64-bit integers once they run out of room with 32-bit
            [Int64]$VideoID = $Source | Get-LastUrlSegment

            $RestArgs["Uri"] = "$API/videos/$VideoID"
        }
        else {
            # Clip provided

            # Strip potential URL formatting
            $Slug = $Source | Get-LastUrlSegment

            if (-not $Force -and $script:TwitchData.ClipInfoCache.ContainsKey($Slug)) {
                # Found cached values to use

                if (-not $TargetIsVideo -and $script:TwitchData.ClipInfoCache[$Slug].Mapping.ContainsKey($Target)) {
                    # Quick return path using cached data
                    return $script:TwitchData.ClipInfoCache[$Slug].Mapping[$Target]
                }
                else {
                    $TimeOffset = New-TimeSpan -Seconds $script:TwitchData.ClipInfoCache[$Slug].Offset
                    $VideoID = $script:TwitchData.ClipInfoCache[$Slug].VideoID
                    # Set REST arguments
                    $RestArgs["Uri"] = "$API/videos/$VideoID"
                }
            }
            else {
                # New uncached source ---- needs additional API call

                # Get information about clip
                $RestArgs["Uri"] = "$API/clips/$Slug"
                $ClipResponse = Invoke-RestMethod @RestArgs

                try {
                    # Verify that the source video was not removed
                    if ($null -eq $ClipResponse.vod) {
                        Write-Error "(Clip) Source video unavailable or deleted." -ErrorId VideoNotFound -Category ObjectNotFound -CategoryTargetName Source -TargetObject $Source -ErrorAction Stop
                    }

                    # Get offset from API response
                    $TimeOffset = New-TimeSpan -Seconds $ClipResponse.vod.offset

                    # Get Video ID from API response
                    [Int64]$VideoID = $ClipResponse.vod.id

                    # Add username to cache
                    if (-not $script:TwitchData.UserInfoCache.ContainsKey($ClipResponse.broadcaster.name)) {
                        $script:TwitchData.UserInfoCache[$ClipResponse.broadcaster.name] = $ClipResponse.broadcaster.id
                    }

                    # Ensure timestamp was converted correctly
                    $ClipResponse.created_at = $ClipResponse.created_at | ConvertTo-UtcDateTime

                    # Add data to clip cache
                    $script:TwitchData.ClipInfoCache[$Slug] = [StreamXRef.ClipObject]@{
                        Offset  = $ClipResponse.vod.offset
                        VideoID = $VideoID
                        Created = $ClipResponse.created_at
                    }

                    # Add mapping for originating video to clip entry
                    $script:TwitchData.ClipInfoCache[$Slug].Mapping[$ClipResponse.broadcaster.name] = $ClipResponse.vod.url

                    $NewDataAdded = $true

                    # Quick return path for when Target is original broadcaster
                    if ($Target -ieq $ClipResponse.broadcaster.name) {
                        return $ClipResponse.vod.url
                    }
                }
                catch [Microsoft.PowerShell.Commands.WriteErrorException] {
                    # Write-Error forwarding and skip to next object in pipeline (if any)
                    $PSCmdlet.WriteError($_)
                    if ($ExplicitNull) {
                        return $null
                    }
                    else {
                        return
                    }
                }
                catch {
                    $PSCmdlet.ThrowTerminatingError($_)
                }

                # Set REST arguments
                $RestArgs["Uri"] = "$API/videos/$VideoID"
            }
        }

        # Get absolute timestamp of event
        # Check cache to see if this video is already known
        if (-not $Force -and $script:TwitchData.VideoInfoCache.ContainsKey($VideoID)) {
            # Use start time from cache
            $EventTimestamp = $script:TwitchData.VideoInfoCache[$VideoID] + $TimeOffset
        }
        else {
            # Get information about main video
            $VodResponse = Invoke-RestMethod @RestArgs

            try {
                # Check for incorrect video type
                if ($VodResponse.broadcast_type -ine "archive") {
                    # Set error message based on Source type
                    $ErrSrc = $SourceIsVideo ? "(Video) Source" : "(Clip) Referenced"

                    # Use "ErrorAction Stop" with specific catch block for forwarding
                    Write-Error "$ErrSrc video is not an archived broadcast." -ErrorId InvalidVideoType -Category InvalidOperation -ErrorAction Stop
                }

                # Ensure timestamp was converted correctly
                $VodResponse.recorded_at = $VodResponse.recorded_at | ConvertTo-UtcDateTime

                # Use start time from API response
                [datetime]$EventTimestamp = $VodResponse.recorded_at + $TimeOffset

                # Add data to Vod cache
                $script:TwitchData.VideoInfoCache[$VideoID] = $VodResponse.recorded_at
                $NewDataAdded = $true
            }
            catch [Microsoft.PowerShell.Commands.WriteErrorException] {
                # Write-Error forwarding and skip to next object in pipeline (if any)
                $PSCmdlet.WriteError($_)
                if ($ExplicitNull) {
                    return $null
                }
                else {
                    return
                }
            }
            catch {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }

        #endregion Source Lookup =======================

        #region Target Lookup ############################

        if ($TargetIsVideo) {
            # Using VOD link

            # 64-bit integer for future-proofing
            [Int64]$TargetID = $Target | Get-LastUrlSegment
            $RestArgs["Uri"] = "$API/videos/$TargetID"

            $Multi = $false
        }
        else {
            # Using username/channel

            # Strip potential URL formatting
            $Target = $Target | Get-LastUrlSegment

            # Check if repeated search using a name that wasn't found during this instance
            if ($NotFoundList -icontains $Target) {
                Write-Error "(Target Username) `"$Target`" not found." -ErrorId UserNotFound -Category ObjectNotFound -CategoryTargetName Target -TargetObject $Target
                if ($ExplicitNull) {
                    return $null
                }
                else {
                    return
                }
            }

            # Get cached user ID number if available or call API if not
            if (-not $Force -and $script:TwitchData.UserInfoCache.ContainsKey($Target)) {
                $UserIdNum = $script:TwitchData.UserInfoCache[$Target]
            }
            else {
                # Get ID number for username using API
                $RestArgs["Uri"] = "$API/users"
                $RestArgs["Body"] = @{
                    "login" = $Target
                }

                $UserLookup = Invoke-RestMethod @RestArgs

                try {
                    # Unlike other API requests, this doesn't return a 404 error if not found
                    if ($UserLookup._total -eq 0) {
                        $NotFoundList.Add($Target)
                        Write-Error "(Target Username) `"$Target`" not found." -ErrorId UserNotFound -Category ObjectNotFound -CategoryTargetName Target -TargetObject $Target -ErrorAction Stop
                    }

                    [int]$UserIdNum = $UserLookup.users[0]._id

                    # Save ID number in user cache
                    $script:TwitchData.UserInfoCache[$Target] = $UserIdNum
                    $NewDataAdded = $true
                }
                catch [Microsoft.PowerShell.Commands.WriteErrorException] {
                    # Write-Error forwarding and skip to next object in pipeline (if any)
                    $PSCmdlet.WriteError($_)
                    if ($ExplicitNull) {
                        return $null
                    }
                    else {
                        return
                    }
                }
                catch {
                    $PSCmdlet.ThrowTerminatingError($_)
                }
            }

            # Set args using ID number
            $RestArgs["Uri"] = "$API/channels/$UserIdNum/videos"
            $RestArgs["Body"] = @{
                "broadcast_type" = "archive"
                "sort"           = "time"
                "limit"          = $Count
                "offset"         = $Offset
            }

            $Multi = $true
        }

        $XRefResponse = Invoke-RestMethod @RestArgs

        try {
            # Check for incorrect video type if Target is a video URL ($Multi will be $false)
            if (-not $Multi -and $XRefResponse.broadcast_type -ine "archive") {
                Write-Error "(Target Video) Video is not an archived broadcast." -ErrorId InvalidVideoType -Category InvalidOperation -CategoryTargetName Target -TargetObject $Target -ErrorAction Stop
            }

            $XRefSet = $Multi ? $XRefResponse.videos : $XRefResponse

            if ($XRefSet -is [array]) {
                for ($i = 0; $i -lt $XRefSet.length; $i++) {
                    $XRefSet[$i].recorded_at = $XRefSet[$i].recorded_at | ConvertTo-UtcDateTime
                }
            }
            else {
                $XRefSet.recorded_at = $XRefSet.recorded_at | ConvertTo-UtcDateTime
            }
        }
        catch [Microsoft.PowerShell.Commands.WriteErrorException] {
            # Write-Error forwarding and skip to next object in pipeline (if any)
            $PSCmdlet.WriteError($_)
            if ($ExplicitNull) {
                return $null
            }
            else {
                return
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }

        #endregion Target Lookup =========================

        # Look for first video that starts before the timestamp

        try {
            $VideoToCompare = $null
            $VideoToCompare = $XRefSet | Where-Object { $_.recorded_at -lt $EventTimestamp } | Select-Object -First 1

            if ($null -eq $VideoToCompare) {
                Write-Error "Event occurs before search range." -ErrorId EventNotInRange -Category ObjectNotFound -CategoryTargetName EventTimestamp -TargetObject $Source -ErrorAction Stop
            }
            elseif ($EventTimestamp -gt $VideoToCompare.recorded_at.AddSeconds($VideoToCompare.length)) {
                # Event timestamp is after the end of stream
                Write-Error "Event not found during stream." -ErrorId EventNotFound -Category ObjectNotFound -CategoryTargetName EventTimestamp -TargetObject $Source -ErrorAction Stop
            }
            else {
                $NewOffset = $EventTimestamp - $VideoToCompare.recorded_at
                $NewUrl = "$($VideoToCompare.url)?t=$($NewOffset.Hours)h$($NewOffset.Minutes)m$($NewOffset.Seconds)s"

                if (-not $SourceIsVideo -and -not $TargetIsVideo) {
                    try {
                        # Add to clip result mapping
                        $script:TwitchData.ClipInfoCache[$Slug].Mapping[$Target] = $NewUrl
                        $NewDataAdded = $true
                    }
                    catch {
                        Write-Verbose "Unable to add result to clip mapping"
                    }
                }

                return $NewUrl
            }
        }
        catch [Microsoft.PowerShell.Commands.WriteErrorException] {
            # Write-Error forwarding and skip to next object in pipeline (if any)
            $PSCmdlet.WriteError($_)
            if ($ExplicitNull) {
                return $null
            }
            else {
                return
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    End {
        if ((Get-EventSubscriber -SourceIdentifier XRefNewDataAdded -Force -ErrorAction Ignore) -and $NewDataAdded) {
            [void] (New-Event -SourceIdentifier XRefNewDataAdded -Sender "Find-TwitchXRef")
        }
    }
}