YouTubeSTT.psm1

#!/usr/bin/env pwsh
using namespace System.IO
using namespace System.Management.Automation

#Requires -Modules cliHelper.core, pipEnv
#Requires -Psedition Core


#region Classes
enum SttOutFormat {
  PSObject
  Markdown
}



# .SYNOPSIS
# A short one-line action-based description, e.g. 'Tests if a function is valid'
# .EXAMPLE
# [YouTubeSTT]::GetTranscript("https://youtu.be/t9b0YBDd0Ho")
class YouTubeSTT {
  static [hashtable]$status = [hashtable]::Synchronized(@{
      HasConfig = [YouTubeSTT]::HasConfig()
    }
  )
  static [string] $Summary_instructions
  static [string] GetvideoId([string]$InputString) {
    $pattern = '(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(?:embed\/)?(?:v\/)?(?:shorts\/)?(?:\S*[^\w\-\s])?(?<id>[\w\-]{11})(?:\S*)?'
    if ($InputString -match $pattern) {
      $videoId = $matches['id']
      return $videoId
    } elseif ($InputString -match '^[\w\-]{11}$') {
      return $InputString
    } else {
      throw [System.ArgumentException]::New('No valid YouTube video ID found in the string.')
    }
  }
  static [string] GetTranscript([string]$videoId) {
    return [YouTubeSTT]::GetTranscript($videoId, $true)
  }
  static [string] GetTranscript([string]$videoId, [bool]$IncludeTitle) {
    return [YouTubeSTT]::GetTranscript($videoId, $IncludeTitle, [SttOutFormat]::PSObject)
  }
  static [string] GetTranscript([string]$videoId, [bool]$IncludeTitle, [SttOutFormat]$OutputFormat) {
    return [YouTubeSTT]::GetTranscript($videoId, $IncludeTitle, $OutputFormat, $false)
  }
  static [string] GetTranscript([string]$videoId, [bool]$IncludeTitle, [SttOutFormat]$OutputFormat, [bool]$IncludeDescription) {
    $vidId = [YouTubeSTT]::GetvideoId($videoId);
    $langOptLinks = [YouTubeSTT]::GetLangOptionsWithLink($vidId); $has_transcript_link = !($langOptLinks.Count -eq 0 -or $null -eq $langOptLinks[0].link)
    if ($has_transcript_link) {
      $link = $langOptLinks[0].link
      # return the video info
      # title, description, transcript
      $markdown = "# Video Transcript`n"
      $videoinfo = [PSCustomObject][ordered]@{
      }
      if ($IncludeTitle) {
        $videoinfo | Add-Member -NotePropertyName 'title' -NotePropertyValue $langOptLinks[0].title
        $markdown += "## Title`n$($langOptLinks[0].title)`n"
      }
      if ($IncludeDescription) {
        $videoinfo | Add-Member -NotePropertyName 'description' -NotePropertyValue $langOptLinks[0].description
        $markdown += "## Description`n$($langOptLinks[0].description)`n"
      }
      $videoinfo | Add-Member -NotePropertyName 'language' -NotePropertyValue $langOptLinks[0].language
      $markdown += "## Language`n$($langOptLinks[0].language)`n"
      $videoinfo | Add-Member -NotePropertyName 'transcript' -NotePropertyValue ([YouTubeSTT]::GetRawTranscript($link))
      $markdown += @"
## Transcript
| Start | Duration | Text |
| :------- | :------ | :------ |`n
"@

      foreach ($part in $videoinfo.transcript) {
        $markdown += "| $($part.start) | $($part.duration) | $($part.text) |`n"
      }
      if ($OutputFormat -eq 'Markdown') {
        return $markdown
      } else {
        return $videoinfo
      }
    }
    # Offline transcribe
    [void][YouTubeSTT]::ResolveRequirements(); $_c = [YouTubeSTT].config; $tmpfile = [IO.Path]::GetTempFileName()
    $_t = [IO.Path]::Combine(($_c.backgroundScript | Split-Path), "transcribe.py"); $dir = $_c.workingDirectory
    $Process = Start-Process -FilePath "python" -ArgumentList "$_t --video_id `"$vidId`" --outfile `"$tmpfile`" --working-directory `"$dir`"" -WorkingDirectory $dir -PassThru -NoNewWindow;
    $Process.WaitForExit(); $process.Kill(); $Process.Dispose()
    $res = [IO.File]::ReadAllText($tmpfile); [IO.File]::Delete($tmpfile)
    return $res
  }
  static [string] GetVideoPageHtml([string]$videoId) {
    try {
      $response = Invoke-WebRequest -Uri "https://www.youtube.com/watch?v=$videoId" -Verbose:$false
      $html = $response.Content
      # Check if the HTML content contains the video URL: <meta property="og:url" content="https://www.youtube.com/watch?v=GikIJpUv6oo">
      if ($html -match 'og:url') {
        # Check if the HTML content contains 'class="g-recaptcha"'
        if ($html -match 'class="g-recaptcha"') {
          Write-Host "Failed to get the HTML content Too Many Requests for video ID: $videoId"
          return $null
        }
        # Check if the HTML content contains '"playabilityStatus":'
        if ($html -notmatch '"playabilityStatus":') {
          Write-Host "Failed to get the HTML content Video Unavailable for video ID: $videoId"
          return $null
        }
        return $html
      } else {
        Write-Host "Failed to get the HTML content for video ID: $videoId"
        return $null
      }
    } catch {
      Write-Host "Failed to get the HTML content for video ID: $videoId"
      return $null
    }
  }
  static [string[]] GetLangOptionsWithLink([string]$videoId) {
    $videoPageHtml = [YouTubeSTT]::GetVideoPageHtml($videoId)
    if (!$videoPageHtml) {
      Write-Host 'Failed to get video page HTML'
      return @()
    }
    $splittedHtml = $videoPageHtml -split '"captions":'
    if ($splittedHtml.Length -lt 2) {
      Write-Host 'No Caption Available'
      return @() # No Caption Available
    }

    try {
      $JsonregexPattern = '{(?:[^{}]|(?<Open>{)|(?<-Open>}))*(?(Open)(?!))}'
      $captionsJson = $splittedHtml[1] -split ',"videoDetails' | Select-Object -First 1
      $videoDetailsJson = ([regex]::Match(($splittedHtml[1] -split ',"videoDetails')[1], $JsonregexPattern).Value | ConvertFrom-Json)
      $captions = ConvertFrom-Json $captionsJson
      # Extract the caption tracks: baseUrl=/api/timedtext?...... this url does expire after some time
      $captionTracks = $captions.playerCaptionsTracklistRenderer.captionTracks
      # This will give the language options
      # if $_.name.runs.text else $_.name.simpleText

      $languageOptions = $captionTracks | ForEach-Object {
        if ($_.name.runs.text) {
          $_.name.runs.text
        } else {
          $_.name.simpleText
        } }

      # Looks like most will be 'English (auto-generated)' and 'English' azurming this is manuly created, so the one we want over auto-generated
      $languageOptions = $languageOptions | Sort-Object {
        if ($_ -eq 'English') {
          return -1
        } elseif ($_ -match 'English') {
          return 0
        } else {
          return 1
        }
      }

      $languageOptionsWithLink = $languageOptions | ForEach-Object {
        $langName = $_
        # $link = ($captionTracks | Where-Object { $_.name.runs[0].text -or $_.name.simpleText -eq $langName }).baseUrl
        $link = $captionTracks | ForEach-Object {
          $name = if ($_.name.runs) { $_.name.runs[0].text } else { $_.name.simpleText }
          if ($name -eq $langName) { $_.baseUrl }
        } | Select-Object -First 1
        [PSCustomObject]@{
          title       = $videoDetailsJson.title
          description = $videoDetailsJson.shortDescription
          language    = $langName
          link        = $link
        }
      }

      return $languageOptionsWithLink
    } catch {
      Write-Host 'Error parsing captions JSON'
      return $null
    }
  }
  static [bool] ResolveRequirements() {
    $_c = [YouTubeSTT].config; $req = $_c.requirementsfile; $res = [IO.File]::Exists($req);
    if (!$res) { throw "YouTubeSTT failed to resolve pip requirements. From file: '$req'." }
    Write-Console "Found file @$(Invoke-PathShortener $req)" -f LemonChiffon;
    if (![YouTubeSTT]::status.HasConfig) { throw [InvalidOperationException]::new("YouTubeSTT config found.") };
    if ($_c.env.State -eq "Inactive") { $_c.env.Activate() }
    Write-Console "(YouTubeSTT) " -f SlateBlue -NoNewLine; Write-Console "၊▹ Resolve pip requirements ... " -f LemonChiffon -NoNewLine -Animate
    pip install -r $req
    Write-Console "Done" -f LimeGreen
    return $res
  }
  static [bool] HasConfig() {
    if ($null -eq [YouTubeSTT].config) { [YouTubeSTT].PsObject.Properties.Add([PSScriptproperty]::New("config", { return [YouTubeSTT]::LoadConfig() }, { throw [SetValueException]::new("config can only be imported or edited") })) }
    return $null -ne [YouTubeSTT].config
  }
  static [PsObject] LoadConfig() {
    return [YouTubeSTT]::LoadConfig((Resolve-Path .).Path)
  }
  static [PsObject] LoadConfig([string]$current_path) {
    # .DESCRIPTION
    # Load the configuration from json or toml file
    $module_path = (Get-Module YouTubeSTT -ListAvailable -Verbose:$false).ModuleBase
    # default config values
    $c = @{
      workingDirectory = $current_path
      requirementsfile = [IO.Path]::Combine($module_path, "Private", "requirements.txt")
      backgroundScript = [IO.Path]::Combine($module_path, "Private", "transcribe.py")
      outFile          = [IO.Path]::Combine($current_path, "$(Get-Date -Format 'yyyyMMddHHmmss')_output.json")
    } -as "PsRecord"
    $c.PsObject.Properties.Add([PSScriptproperty]::New("env", { return [YouTubeSTT].config.workingDirectory | New-pipEnv }, { throw [SetValueException]::new("env is read-only") }))
    $c.PsObject.Properties.Add([PSScriptproperty]::New("modulePath", [scriptblock]::Create("return `"$module_path`""), { throw [SetValueException]::new("modulePath is read-only") }))
    return $c
  }
  static [string] GetRawTranscript([string]$link) {
    if (!$link.StartsWith('https://www.youtube.com')) {
      $uri = ('https://www.youtube.com{0}' -f $link)
    } else {
      $uri = $link
    }
    $transcriptPageResponse = Invoke-WebRequest -Uri $uri -Verbose:$false
    [xml]$xmlDoc = [xml](New-Object System.Xml.XmlDocument)
    $xmlDoc.LoadXml($transcriptPageResponse.Content)
    $textNodes = $xmlDoc.documentElement.ChildNodes
    $transcriptParts = @()
    foreach ($node in $textNodes) {
      $transcriptParts += [PSCustomObject]@{
        start    = $node.GetAttribute('start')
        duration = $node.GetAttribute('dur')
        text     = [System.Web.HttpUtility]::HtmlDecode($node.InnerText)
      }
    }
    return $transcriptParts
  }
}
#endregion Classes

# Types that will be available to users when they import the module.
$typestoExport = @(
  [YouTubeSTT]
)
$TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
foreach ($Type in $typestoExport) {
  if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) {
    $Message = @(
      "Unable to register type accelerator '$($Type.FullName)'"
      'Accelerator already exists.'
    ) -join ' - '

    [System.Management.Automation.ErrorRecord]::new(
      [System.InvalidOperationException]::new($Message),
      'TypeAcceleratorAlreadyExists',
      [System.Management.Automation.ErrorCategory]::InvalidOperation,
      $Type.FullName
    ) | Write-Warning
  }
}
# Add type accelerators for every exportable type.
foreach ($Type in $typestoExport) {
  $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  foreach ($Type in $typestoExport) {
    $TypeAcceleratorsClass::Remove($Type.FullName)
  }
}.GetNewClosure();

$scripts = @();
$Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += $Public

foreach ($file in $scripts) {
  Try {
    if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue }
    . "$($file.fullname)"
  } Catch {
    Write-Warning "Failed to import function $($file.BaseName): $_"
    $host.UI.WriteErrorLine($_)
  }
}

$Param = @{
  Function = $Public.BaseName
  Cmdlet   = '*'
  Alias    = '*'
  Verbose  = $false
}
Export-ModuleMember @Param