<# .SYNOPSIS Downloads, decrypts and repackages content from Hoopla .DESCRIPTION Uses a HooplaDigital.com account to download DRM-free copies of ebooks, comics, and/or audiobooks available on the platform. Content that is not already borrowed on the account will be borrowed if slots are available. Content that is not borrowed cannot be downloaded. * E-Books are downloaded to epub files (most) or cbz (rare, picture books). * Comic books are downloaded to cbz files. * Audiobooks are downloaded to m4a files. (single file, and very little metadata available from Hoopla, such as chapters) .PARAMETER Credential Credential to use for logging into Hoopla site. (Cannot be used with Username and Password parameters) .PARAMETER Username Username to use for logging into Hoopla site. (Cannot be used with Credential parameter) .PARAMETER Password Password to use for logging into Hoopla site. (Cannot be used with Credential parameter) .PARAMETER TitleId Specifies one or more title IDs of content to download. .PARAMETER OutputFolder Sets the output folder for downloaded content. Defaults to current directory. .PARAMETER PatronId Override default patron id for Hoopla. (This is rarely required as most user accounts are only tied to a single patron). .PARAMETER EpubZipBin Specifies path to epubzip binary. Else look for one beside script, or in system path. .PARAMETER FfmpegBin Specifies path to ffmpeg binary. Else look for one beside script, or in system path. .PARAMETER KeepDecryptedData If set, don't delete the intermediary data after decryption, before final output file. For ebooks, this is xml, images, and the manifest. For comics, it is images. For audiobooks, it is mp4 ts files. This is typically only useful for development or troubleshooting. .PARAMETER KeepEncryptedData If set, don't delete the encrypted data as downloaded from Hoopla's servers. This is typically only useful for development or troubleshooting. .PARAMETER AllBorrowed This parameter is deprecated. If TitleId is not set, it is implied that all borrowed titles will be downloaded. .PARAMETER AudioBookForceSingleFile If set, leave audiobook as single file, as if chapter data is not present. .EXAMPLE .\Invoke-HooplaDownload.ps1 123456 Downloads Hoopla content with title id 123456 .NOTES Author: kabutops728 - My Anonamouse Version: 2.9 #> [CmdletBinding(DefaultParameterSetName='CredentialSingleTitle')] param( [int64[]] $TitleId, [Parameter(Mandatory,ParameterSetName='CredentialSingleTitle')] [Management.Automation.PSCredential] $Credential, [Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')] [string] $Username, [Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')] [string] $Password, [ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Container})] [string]$OutputFolder = $PSScriptRoot, [int64]$PatronId, [string]$EpubZipBin, [string]$FfmpegBin, [switch]$KeepDecryptedData, [switch]$KeepEncryptedData, [switch]$AudioBookForceSingleFile, # Deprecated [switch]$AllBorrowed ) $USER_AGENT = 'Hoopla Android/4.27' $HEADERS = @{ 'app' = 'ANDROID' 'app-version' = '4.27.1' 'device-module' = 'KFKAWI' 'device-version' = '' 'hoopla-verson' = '4.27.1' 'kids-mode' = 'false' 'os' = 'ANDROID' 'os-version' = '6.0.1' 'ws-api' = '2.1' 'Host' = 'hoopla-ws.hoopladigital.com' } $URL_HOOPLA_WS_BASE = 'https://hoopla-ws.hoopladigital.com' $URL_HOOPLA_LIC_BASE = 'https://hoopla-license2.hoopladigital.com' $COMIC_IMAGE_EXTS = @('.jpg','.png','.jpeg','.gif','.bmp','.tif','.tiff') enum HooplaKind { EBOOK = 5 MUSIC = 6 MOVIE = 7 AUDIOBOOK = 8 TELEVISION = 9 COMIC = 10 } $SUPPORTED_KINDS = @([HooplaKind]::EBOOK, [HooplaKind]::COMIC, [HooplaKind]::AUDIOBOOK) Function Connect-Hoopla { param( [Parameter(Mandatory)][Management.Automation.PSCredential]$Credential ) $username = $Credential.UserName $password = $Credential.GetNetworkCredential().Password $res = Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/tokens" -Method Post -Headers $HEADERS -UserAgent $USER_AGENT -Body @{username = $username; password = $password} if ($res.tokenStatus -ne 'SUCCESS') { throw $res.message } $res.token } Function Get-HooplaUsers { param( [Parameter(Mandatory)][string]$Token ) $h = $HEADERS.Clone() $h['Authorization'] = "Bearer $Token" Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users" -Method Get -Headers $h -UserAgent $USER_AGENT } Function Get-HooplaTitleInfo { param( [Parameter(Mandatory)][int64]$PatronId, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][int64]$TitleId ) $h = $HEADERS.Clone() $h['Authorization'] = "Bearer $Token" $h['patron-id'] = $PatronId Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/v2/titles/$TitleId" -Method Get -Headers $h -UserAgent $USER_AGENT } Function Get-HooplaBorrowsRemaining { param( [Parameter(Mandatory)][string]$UserId, [Parameter(Mandatory)][int64]$PatronId, [Parameter(Mandatory)][string]$Token ) $h = $HEADERS.Clone() $h['Authorization'] = "Bearer $Token" $h['patron-id'] = $PatronId Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrows-remaining" -Method Get -Headers $h -UserAgent $USER_AGENT } Function Get-HooplaBorrowedTitles { param( [Parameter(Mandatory)][string]$UserId, [Parameter(Mandatory)][int64]$PatronId, [Parameter(Mandatory)][string]$Token ) $h = $HEADERS.Clone() $h['Authorization'] = "Bearer $Token" $h['patron-id'] = $PatronId Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/borrowed-titles" -Method Get -Headers $h -UserAgent $USER_AGENT } Function Invoke-HooplaBorrow { param( [Parameter(Mandatory)][string]$UserId, [Parameter(Mandatory)][int64]$PatronId, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][int64]$TitleId ) $h = $HEADERS.Clone() $h['Authorization'] = "Bearer $Token" $h['patron-id'] = $PatronId Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrowed-titles/$TitleId" -Method Post -Headers $h -UserAgent $USER_AGENT } Function Invoke-HooplaZipDownload { param( [Parameter(Mandatory)][int64]$PatronId, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][int64]$CircId, [Parameter(Mandatory)][ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Leaf})][string]$OutFile ) $h = $HEADERS.Clone() $h['Authorization'] = "Bearer $Token" $h['patron-id'] = $PatronId $res = Invoke-WebRequest -Uri "$URL_HOOPLA_WS_BASE/patrons/downloads/$CircId/url" -Method Get -Headers $h -UserAgent $USER_AGENT -UseBasicParsing if ($PSVersionTable.PSVersion.Major -ge 6) { Invoke-WebRequest -Uri $res.Headers['Location'][0] -Method Get -UseBasicParsing -OutFile $OutFile } else { Invoke-WebRequest -Uri $res.Headers['Location'] -Method Get -UseBasicParsing -OutFile $OutFile } } Function Get-HooplaKey { param( [Parameter(Mandatory)][int64]$PatronId, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][int64]$CircId ) $h = $HEADERS.Clone() $h['Authorization'] = "Bearer $Token" $h['patron-id'] = $PatronId Invoke-RestMethod -Uri "$URL_HOOPLA_LIC_BASE/downloads/$CircId/key" -Method Get -Headers $h -UserAgent $USER_AGENT } Function Get-FileKeyKey { param( [Parameter(Mandatory)][int64]$CircId, [Parameter(Mandatory)][DateTime]$Due, [Parameter(Mandatory)][int64]$PatronId ) $combined = '{0:yyyyMMddHHmmss}:{1}:{2}' -f $Due, $PatronId, $CircId [Security.Cryptography.HashAlgorithm]::Create('SHA1').ComputeHash([Text.Encoding]::UTF8.GetBytes($combined)) | Select-Object -First 16 } Function Decrypt-FileKey { param( [Parameter(Mandatory)][byte[]]$FileKeyEnc, [Parameter(Mandatory)][byte[]]$FileKeyKey ) $aesManaged = New-Object "System.Security.Cryptography.AesManaged" $aesManaged.Mode = [Security.Cryptography.CipherMode]::ECB $aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7 $aesManaged.BlockSize = 128 $aesManaged.KeySize = 128 $aesManaged.Key = $FileKeyKey $decryptor = $aesManaged.CreateDecryptor(); $unencryptedData = $decryptor.TransformFinalBlock($FileKeyEnc, 0, $FileKeyEnc.Length); $aesManaged.Dispose() $unencryptedData } Function Decrypt-File { param( [Parameter(Mandatory)][byte[]]$FileKey, [Parameter(Mandatory)][string]$MediaKey, [Parameter(Mandatory)][string]$InputFileName, [Parameter(Mandatory)][string]$OutputFileName ) $aesManaged = New-Object "System.Security.Cryptography.AesManaged" $aesManaged.Mode = [Security.Cryptography.CipherMode]::CBC $aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7 $aesManaged.BlockSize = 128 $aesManaged.KeySize = 256 $aesManaged.Key = $FileKey $aesManaged.IV = [Text.Encoding]::UTF8.GetBytes($MediaKey) | Select-Object -First 16 $fileStreamReader = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $InputFileName, ([IO.FileMode]::Open), ([IO.FileShare]::Read) $fileStreamWriter = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $OutputFileName, ([IO.FileMode]::Create) $FileStreamReader.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null $decryptor = $aesManaged.CreateDecryptor() $cryptoStream = New-Object -TypeName 'System.Security.Cryptography.CryptoStream' -ArgumentList $fileStreamWriter, $decryptor, ([Security.Cryptography.CryptoStreamMode]::Write) $fileStreamReader.CopyTo($cryptoStream) $cryptoStream.FlushFinalBlock() $cryptoStream.Close() $fileStreamReader.Close() $fileStreamWriter.Close() $aesManaged.Dispose() } Function Test-Mp4 { param( [Parameter(Mandatory, Position=0)] [Alias('LiteralPath')] [string]$Path ) $fileStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $Path, ([IO.FileMode]::Open), ([IO.FileShare]::Read) $fileReader = New-Object -TypeName 'System.IO.BinaryReader' -ArgumentList $fileStream -ErrorAction Stop $head = $fileReader.ReadBytes(8) $fileReader.Dispose() $fileStream.Dispose() return [Text.Encoding]::ASCII.GetString(($head | Select-Object -Skip 4)) -eq 'ftyp' } Function Remove-InvalidFileNameChars { param( [Parameter(Mandatory,Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [String]$Name ) $invalidChars = [IO.Path]::GetInvalidFileNameChars() -join '' $re = "[{0}]" -f [RegEx]::Escape($invalidChars) $Name -replace $re, '_' } Function Convert-HooplaDecryptedToEpub { param( [Parameter(Mandatory)][string]$InputFolder, [Parameter(Mandatory)][string]$OutFolder ) $container = [xml](Get-Content -LiteralPath (Join-Path -Path $InputFolder -ChildPath 'META-INF\container.xml') -Raw) $rootFile = $container.container.rootfiles.rootfile | Select-Object -ExpandProperty Full-Path $contentFile = (Join-Path -Path $InputFolder -ChildPath $rootFile).Trim() $contentRoot = Get-Item -LiteralPath $contentFile | Select-Object -ExpandProperty Directory $content = [xml](Get-Content -LiteralPath $contentFile) $fileList = $content.package.manifest.item | Select-Object -ExpandProperty href | ForEach-Object -Process { (Join-Path -Path $contentRoot -ChildPath ([Web.HttpUtility]::UrlDecode($_))).Trim() } $fileList += $contentFile $fileList = $fileList | Sort-Object -Unique $title = $content.package.metadata.title | Select-Object -First 1 if ($title.GetType() -ne [String]) { $title = $content.package.metadata.title | Select-Object -First 1 | Select-Object -ExpandProperty '#text' } $author = $content.package.metadata.creator | Select-Object -First 1 if ($author.GetType() -ne [String]) { $author = $content.package.metadata.creator | Select-Object -First 1 | Select-Object -ExpandProperty '#text' } # Usually, content root is a subfolder of the input folder. But sometimes, they are the same. Make sure we declutter the input root if they differ, and always keep the mimetype file. $mimeTypeFile = Join-Path -Path $InputFolder -ChildPath 'mimetype' $extra = @(Get-ChildItem -LiteralPath $contentRoot -File -Recurse | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) }) $extra += Get-ChildItem -LiteralPath $InputFolder -File | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) } $extra = $extra | Sort-Object -Property FullName -Unique $extra | Remove-Item $containerXmlFolder = Join-Path -Path $contentRoot.FullName -ChildPath 'META-INF' $containerXmlPath = Join-Path -Path $containerXmlFolder -ChildPath 'container.xml' if (!(Test-Path -LiteralPath $containerXmlPath -PathType Leaf)) { New-Item -Path $containerXmlFolder -ItemType Directory -Force | Out-Null $xml = @" "@ $xml | Out-File -LiteralPath $containerXmlPath -Encoding ascii } $finalFile = ('{0} - {1}.epub' -f $title, $author) | Remove-InvalidFileNameChars Push-Location Set-Location -LiteralPath $InputFolder $finalFileFullPath = (Join-Path -Path $OutFolder -ChildPath $finalFile) if ($VerbosePreference -eq 'Continue') { & $EpubZipBin $finalFileFullPath } else { & $EpubZipBin $finalFileFullPath >$null 2>&1 } Pop-Location Get-Item -LiteralPath $finalFileFullPath } Function Convert-HooplaDecryptedToCbz { param( [Parameter(Mandatory)][string]$InputFolder, [Parameter(Mandatory)][string]$OutFolder, [Parameter(Mandatory)][string]$Name ) $fileName = $Name | Remove-InvalidFileNameChars $tempOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.zip" $finalOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.cbz" Compress-Archive -Path ( Get-ChildItem -LiteralPath $InputFolder | Where-Object -FilterScript { $_.Extension -in $COMIC_IMAGE_EXTS } | Select-Object -ExpandProperty FullName ) -CompressionLevel Fastest -DestinationPath $tempOutFile Rename-Item -LiteralPath $tempOutFile -NewName $finalOutFile Get-Item $finalOutFile } Function Convert-HooplaDecryptedToM4a { param( [Parameter(Mandatory)][string]$InputFolder, [Parameter(Mandatory)][string]$OutFolder, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string]$Author, [Parameter(Mandatory)][int]$Year, [string]$Subtitle, [object]$ChapterData ) if ($Author) { $baseFileName = ('{0} - {1}' -f $Name, $Author) | Remove-InvalidFileNameChars } else { $baseFileName = $Name | Remove-InvalidFileNameChars } $finalOutFile = Join-Path -Path $OutFolder -ChildPath ('{0}.m4a' -f $baseFileName) $inFile = Get-ChildItem -LiteralPath $InputFolder -Filter '*.m3u8' | Select-Object -First 1 | Select-Object -ExpandProperty FullName Push-Location Set-Location $InputFolder $ffArgs = @( '-y', '-i', $infile, '-metadata', ('title="{0}"' -f $Title), '-metadata', ('year="{0}"' -f $Year), '-metadata', ('author="{0}"' -f $Author), '-metadata', 'genre="Audiobook"' ) if ($Subtitle) { $ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle) } $ffArgs += @( '-c:a', 'copy', $finalOutFile ) if ($VerbosePreference -eq 'Continue') { & $FfmpegBin @ffArgs #& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile } else { & $FfmpegBin @ffArgs >$null 2>&1 #& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile >$null 2>&1 } if ($ChapterData -and (!$AudioBookForceSingleFile)) { $outDir = New-Item -Path (Join-Path -Path $OutFolder -ChildPath $baseFileName) -ItemType Directory $chapterCount = $ChapterData | Select-Object -ExpandProperty chapter | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum $ChapterData | ForEach-Object -Process { $ffArgs = @( '-y', '-i', $finalOutFile, '-ss', $_.start, '-t', $_.duration, '-metadata', ('title="{0}"' -f $_.title), '-metadata', ('album="{0}"' -f $Title), '-metadata', ('year="{0}"' -f $Year), '-metadata', ('author="{0}"' -f $Author), '-metadata', 'genre="Audiobook"' '-metadata', ('track={0}/{1}' -f $_.ordinal, $chapterCount) ) if ($Subtitle) { $ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle) } $ffArgs += @( '-c', 'copy', (Join-Path -Path $outDir.FullName -ChildPath ('{0} - {1} - {2}.m4a' -f $baseFileName, $_.ordinal, ($_.title | Remove-InvalidFileNameChars))) ) if ($VerbosePreference -eq 'Continue') { & $FfmpegBin @ffArgs } else { & $FfmpegBin @ffArgs >$null 2>&1 } } Remove-Item $finalOutFile $finalOutFile = $outDir } Pop-Location Get-Item $finalOutFile } if (!$Credential) { $ssPassword = ConvertTo-SecureString $Password -AsPlainText -Force $Credential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $Username, $ssPassword } if ((!$AllBorrowed) -and ($null -eq $TitleId)) { Write-Warning 'No -TitleId specified. All currently-borrowed titles will be downloaded.' $AllBorrowed = $true } $AppExtension = '' if (($PSVersionTable.PSVersion -lt '6.0') -or $IsWindows) { $AppExtension = '.exe' } $cmd = '' if ($EpubZipBin) { $cmd = Get-Command -Name $EpubZipBin -ErrorAction SilentlyContinue if (!$cmd) { Write-Warning "Epubzip binary specified was not found ($EpubZipBin). Will try to use alternate version if available." } } if (!$cmd) { $cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "epubzip$AppExtension") -ErrorAction SilentlyContinue if (!$cmd) { $cmd = Get-Command -Name "epubzip$AppExtension" -ErrorAction SilentlyContinue if (!$cmd) { Write-Warning "Epubzip binary not found ($EpubZipBin). If you are downloading ebooks (rather than comics or audiobooks), you may wish to download the binary from https://github.com/dino-/epub-tools/releases, specify a different path with -EpubZipBin, or specify -KeepDecryptedData so that you can manually pack afterward." } } $EpubZipBin = $cmd.Source } Write-Verbose ('Using epubzip bin: "{0}"' -f $EpubZipBin) $cmd = '' if ($FfmpegBin) { $cmd = Get-Command -Name $FfmpegBin -ErrorAction SilentlyContinue if (!$cmd) { Write-Warning "FFMpeg binary specified was not found ($FfmpegBin). Will try to use alternate version if available." } } if (!$cmd) { $cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "ffmpeg$AppExtension") -ErrorAction SilentlyContinue if (!$cmd) { $cmd = Get-Command -Name "ffmpeg$AppExtension" -ErrorAction SilentlyContinue if (!$cmd) { Write-Warning "FFmpeg binary not found. If you are downloading audiobooks (rather than ebooks or comics), you may wish to download the binary from https://ffmpeg.zeranoe.com/builds/, specify a different path with -FfmpegBin, or specify -KeepDecryptedData so that you can manually convert afterward." } } $FfmpegBin = $cmd.Source } Write-Verbose ('Using ffpmeg bin: "{0}"' -f $FfmpegBin) if (!(Test-Path -LiteralPath $OutputFolder -PathType Container)) { Write-Warning "Output folder doesn't exist. Creating." New-Item -Path $OutputFolder -ItemType Directory | Out-Null } $OutputFolder = Get-Item -LiteralPath $OutputFolder | Select-Object -ExpandProperty $_.FullName $token = Connect-Hoopla -Credential $Credential Write-Verbose "Logged in. Received token $($token -replace '\-.*', '-****-****-****-************')" $users = Get-HooplaUsers $token Write-Verbose "Found $($users.patrons.Count) patrons" $userId = $users.id if (!$PatronId) { if ($users.patrons.Count -eq 0) { throw "No patrons found on account. Account may not be correctly set up with library." } elseif ($users.patrons.Count -gt 1) { Write-Warning ( "Multiple patrons found on account. Using first one, {0} ({1}). You can specify -PatronId to override" -f $users.patrons[0].id, $users.patrons[0].libraryName ) } $PatronId = $users.patrons[0].id Write-Verbose "Using PatronId $PatronId" } $borrowedRaw = Get-HooplaBorrowedTitles -Token $token -UserId $userId -PatronId $PatronId $borrowed = $borrowedRaw | Where-Object -FilterScript { $_.kind.id -in $SUPPORTED_KINDS } Write-Verbose "Found $($borrowed.Count) ($($borrowedRaw.Count)) titles already borrowed" $toDownload = @() if ($AllBorrowed) { $toDownload = $borrowed } else { $toDownload = $borrowed | Where-Object -FilterScript { $_.id -in $TitleId } $allBorrowedTitles = $borrowed | Select-Object -ExpandProperty id $toBorrow = $TitleId | Where-Object -FilterScript { $_ -notin $allBorrowedTitles } if ($toBorrow) { $borrowsRemainingData = Get-HooplaBorrowsRemaining -UserId $userId -PatronId $PatronId -Token $token Write-Host $borrowsRemainingData.borrowsRemainingMessage $borrowsRemaining = $borrowsRemainingData.borrowsRemaining $toBorrow | ForEach-Object -Process { Write-Host "Title $_ is not already borrowed or is not a supported kind. Looking up data about it." $titleInfo = Get-HooplaTitleInfo -PatronId $PatronId -Token $token -TitleId $_ if ($titleInfo.kind.id -in $SUPPORTED_KINDS) { if ((--$borrowsRemaining) -le 0) { Write-Warning "Title $_ ($($titleInfo.Title)) not borrowed already, but we're out of remaining borrows allowed. Skipping..." } else { Write-Host "Borrowing title $_ ($($titleInfo.Title))..." $res = Invoke-HooplaBorrow -UserId $userId -PatronId $PatronId -Token $token -TitleId $titleInfo.id Write-Host "Response: $($res.message)" $newToDownload = $res.titles | Where-Object -FilterScript { $_.id -eq $titleInfo.id } if ($newToDownload) { $toDownload += $newToDownload } else { Write-Warning "Failed to borrow title $_ ($($titleInfo.Title))..." } } } else { Write-Warning "Title $_ is not a supported kind ($($titleInfo.kind.name)). Skipping..." } } } } $tempFolder = [IO.Path]::GetTempPath() $now = Get-Date $toDownload | ForEach-Object -Process { $info = $_ $contentKind = [HooplaKind]$_.kind.id if ($_.contents.mediaType) { $contentKind = [HooplaKind]$_.contents.mediaType } $contents = $info.contents $circId = $contents.circId $mediaKey = $contents.mediaKey $dueUnix = [Math]::Truncate($info.contents.due / 1000) $due = (New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc)).AddSeconds($dueUnix) $circFileName = (Join-Path -Path $tempFolder -ChildPath "$($circId).zip") Invoke-HooplaZipDownload -PatronId $patronId -Token $token -CircId $circId -OutFile $circFileName $keyData = Get-HooplaKey -PatronId $patronId -Token $token -CircId $circId $fileKeyKey = Get-FileKeyKey -CircId $circId -Due $due -PatronId $patronId $fileKey = Decrypt-FileKey -FileKeyEnc ([Convert]::FromBase64String($keyData."$mediaKey")) -FileKeyKey $fileKeyKey $encDir = Join-Path -Path $tempFolder -ChildPath ('enc-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now) New-Item -Path $encDir -ItemType Directory | Out-Null Expand-Archive -LiteralPath $circFileName -DestinationPath $encDir Remove-Item -LiteralPath $circFileName $decDir = Join-Path -Path $tempFolder -ChildPath ('dec-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now) New-Item -Path $decDir -ItemType Directory | Out-Null $activity = 'Decrypting Content ({0})' -f $_.title Write-Progress -Activity $activity -PercentComplete 0 $zipFiles = Get-ChildItem $encDir -Recurse -File $decDone = 0 $decTotal = $zipFiles.Count $zipFiles | ForEach-Object -Process { $outFile = $_.FullName.Replace($encDir, $decDir) $outDir = $_.DirectoryName.Replace($encDir, $decDir) if (!(Test-Path -LiteralPath $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } if (($contentKind -eq [HooplaKind]::AUDIOBOOK) -and ($_.Extension -eq '.m3u8')) { $lines = Get-Content -LiteralPath $_.FullName | Where-Object -FilterScript {$_ -notmatch '^#EXT-X-KEY'} # Out-File doesn't support utf8 w/o BOM [IO.File]::WriteAllLines($outFile, $lines) return } if ($_.Length) { # Hack. Some ebooks contain audio files that download as unencrypted if (($_.Extension -eq '.m4a') -and (Test-Mp4 -LiteralPath $_.FullName)) { Write-Verbose -Message ('Coping unencrypted {0}' -f $_.FullName) Copy-Item -LiteralPath $_.FullName -Destination $outFile } else { Write-Verbose -Message ('Decrypting {0}' -f $_.FullName) Decrypt-File -FileKey $fileKey -MediaKey $mediaKey -InputFileName $_.FullName -OutputFileName $outFile } } else { Write-Verbose -Message ('Writing empty file {0}' -f $_.FullName) '' | Out-File -LiteralPath $outFile } Write-Progress -Activity $activity -PercentComplete ((++$decDone) / $decTotal * 100) } Write-Progress -Activity $activity -Completed switch ($contentKind) { ([HooplaKind]::EBOOK) { Convert-HooplaDecryptedToEpub -InputFolder $decDir -OutFolder $OutputFolder } ([HooplaKind]::COMIC) { $title = $contents.title $subtitle = $contents.subtitle $name = $title if ($subtitle) { $name += ", $subtitle" } Convert-HooplaDecryptedToCbz -InputFolder $decDir -OutFolder $OutputFolder -Name $name } ([HooplaKind]::AUDIOBOOK) { Convert-HooplaDecryptedToM4a -InputFolder $decDir -OutFolder $OutputFolder -Name $info.title -Title $info.title ` -Year $info.year -Author $info.artist.name -Subtitle $contents.subtitle -ChapterData $contents.chapters } } if (!$KeepDecryptedData) { Remove-Item -LiteralPath $decDir -Recurse } else { Write-Host ('Decrypted data for {0} ({1}) stored in {2}' -f $_.id, $_.title, $decDir) } if (!$KeepEncryptedData) { Remove-Item -LiteralPath $encDir -Recurse } }