Files
HAOVP/backend/import_category_videos.ps1

764 lines
25 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
param(
[string]$VideoRoot = 'D:\视频库',
[string]$BaseUrl = 'http://192.168.1.101:9000',
[string]$OutputPath = '',
[string]$AdminUsername = 'admin',
[string]$AdminPassword = 'admin123',
[string]$DefaultUserPassword = 'Pass@123456',
[string]$RedisHost = '192.168.1.101',
[int]$RedisPort = 6379,
[string]$RedisPassword = '',
[switch]$ReuseExistingUsers
)
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = Join-Path $PSScriptRoot 'VIDEO_IMPORT_RESULTS.json'
}
function New-OrderedMap {
return [ordered]@{}
}
function ConvertTo-PlainObject {
param([object]$Value)
if ($null -eq $Value) {
return $null
}
if ($Value -is [System.Collections.IDictionary]) {
$result = [ordered]@{}
foreach ($key in $Value.Keys) {
$result[$key] = ConvertTo-PlainObject -Value $Value[$key]
}
return $result
}
if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
$items = @()
foreach ($item in $Value) {
$items += ,(ConvertTo-PlainObject -Value $item)
}
return $items
}
$type = $Value.GetType()
if ($type.FullName -like 'System.Management.Automation.PSCustomObject' -or
($type.Namespace -and $type.Namespace -notlike 'System*')) {
$result = [ordered]@{}
foreach ($prop in $Value.PSObject.Properties) {
$result[$prop.Name] = ConvertTo-PlainObject -Value $prop.Value
}
return $result
}
return $Value
}
function Save-Report {
param([hashtable]$Report)
$plain = ConvertTo-PlainObject -Value $Report
$plain | ConvertTo-Json -Depth 20 | Set-Content -Path $OutputPath -Encoding utf8BOM
}
function Invoke-JsonApi {
param(
[Parameter(Mandatory = $true)][string]$Method,
[Parameter(Mandatory = $true)][string]$Url,
[hashtable]$Headers = @{},
$Body = $null,
[int]$TimeoutSec = 180
)
$params = @{
Method = $Method
Uri = $Url
Headers = $Headers
UseBasicParsing = $true
SkipHttpErrorCheck = $true
TimeoutSec = $TimeoutSec
}
if ($null -ne $Body) {
$params.ContentType = 'application/json'
if ($Body -is [string]) {
$params.Body = $Body
} else {
$params.Body = ($Body | ConvertTo-Json -Depth 20)
}
}
try {
$response = Invoke-WebRequest @params
$json = $null
if ($response.Content) {
try {
$json = $response.Content | ConvertFrom-Json -Depth 50
} catch {
$json = $null
}
}
return [pscustomobject]@{
HttpStatus = [int]$response.StatusCode
Json = $json
Raw = $response.Content
}
} catch {
return [pscustomobject]@{
HttpStatus = -1
Json = $null
Raw = $_.Exception.Message
}
}
}
function Invoke-UploadApi {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$FilePath,
[hashtable]$Headers = @{},
[int]$TimeoutSec = 7200
)
$handler = $null
$httpClient = $null
$fileStream = $null
try {
$handler = New-Object System.Net.Http.HttpClientHandler
$httpClient = New-Object System.Net.Http.HttpClient($handler)
$httpClient.Timeout = [TimeSpan]::FromSeconds($TimeoutSec)
foreach ($headerKey in $Headers.Keys) {
[void]$httpClient.DefaultRequestHeaders.TryAddWithoutValidation($headerKey, [string]$Headers[$headerKey])
}
$multipart = New-Object System.Net.Http.MultipartFormDataContent
$fileStream = [System.IO.File]::OpenRead($FilePath)
$fileContent = New-Object System.Net.Http.StreamContent($fileStream)
$mimeType = switch ([System.IO.Path]::GetExtension($FilePath).ToLowerInvariant()) {
'.mp4' { 'video/mp4' }
'.mov' { 'video/quicktime' }
'.mkv' { 'video/x-matroska' }
'.avi' { 'video/x-msvideo' }
'.webm' { 'video/webm' }
'.m4v' { 'video/x-m4v' }
'.flv' { 'video/x-flv' }
default { 'video/mp4' }
}
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($mimeType)
$multipart.Add($fileContent, 'file', [System.IO.Path]::GetFileName($FilePath))
$response = $httpClient.PostAsync($Url, $multipart).GetAwaiter().GetResult()
$raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
$json = $null
if ($raw) {
try {
$json = $raw | ConvertFrom-Json -Depth 50
} catch {
$json = $null
}
}
return [pscustomobject]@{
HttpStatus = [int]$response.StatusCode
Json = $json
Raw = $raw
}
} catch {
return [pscustomobject]@{
HttpStatus = -1
Json = $null
Raw = $_.Exception.Message
}
} finally {
if ($fileStream) { $fileStream.Dispose() }
if ($httpClient) { $httpClient.Dispose() }
if ($handler) { $handler.Dispose() }
}
}
function Read-DotEnv {
param([string]$Path)
$map = @{}
if (-not (Test-Path -LiteralPath $Path)) {
return $map
}
foreach ($line in Get-Content -LiteralPath $Path) {
if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith('#')) {
continue
}
$parts = $line -split '=', 2
if ($parts.Count -eq 2) {
$map[$parts[0].Trim()] = $parts[1].Trim()
}
}
return $map
}
function Read-RedisLine {
param([System.IO.Stream]$Stream)
$bytes = New-Object System.Collections.Generic.List[byte]
while ($true) {
$b = $Stream.ReadByte()
if ($b -lt 0) {
throw 'Redis 连接已关闭'
}
if ($b -eq 13) {
$next = $Stream.ReadByte()
if ($next -ne 10) {
throw 'Redis 响应格式错误'
}
break
}
$bytes.Add([byte]$b)
}
return [System.Text.Encoding]::UTF8.GetString($bytes.ToArray())
}
function Send-RedisCommand {
param(
[System.IO.Stream]$Stream,
[string[]]$RedisArgs
)
$builder = New-Object System.Text.StringBuilder
[void]$builder.Append("*$($RedisArgs.Count)`r`n")
foreach ($arg in $RedisArgs) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($arg)
[void]$builder.Append("$" + $bytes.Length + "`r`n")
[void]$builder.Append($arg + "`r`n")
}
$payload = [System.Text.Encoding]::UTF8.GetBytes($builder.ToString())
$Stream.Write($payload, 0, $payload.Length)
$Stream.Flush()
$prefix = [char]$Stream.ReadByte()
switch ($prefix) {
'+' { return Read-RedisLine -Stream $Stream }
'-' { throw "Redis 错误: $(Read-RedisLine -Stream $Stream)" }
':' { return [int64](Read-RedisLine -Stream $Stream) }
'$' {
$length = [int](Read-RedisLine -Stream $Stream)
if ($length -lt 0) {
return $null
}
$buffer = New-Object byte[] $length
$offset = 0
while ($offset -lt $length) {
$read = $Stream.Read($buffer, $offset, $length - $offset)
if ($read -le 0) {
throw 'Redis bulk string 读取失败'
}
$offset += $read
}
$null = Read-RedisLine -Stream $Stream
return [System.Text.Encoding]::UTF8.GetString($buffer)
}
'*' {
$count = [int](Read-RedisLine -Stream $Stream)
$items = @()
for ($i = 0; $i -lt $count; $i++) {
$nestedPrefix = [char]$Stream.ReadByte()
if ($nestedPrefix -ne '$') {
throw "暂不支持的 Redis 数组子类型: $nestedPrefix"
}
$len = [int](Read-RedisLine -Stream $Stream)
if ($len -lt 0) {
$items += $null
continue
}
$buf = New-Object byte[] $len
$offset = 0
while ($offset -lt $len) {
$read = $Stream.Read($buf, $offset, $len - $offset)
if ($read -le 0) {
throw 'Redis 数组元素读取失败'
}
$offset += $read
}
$null = Read-RedisLine -Stream $Stream
$items += [System.Text.Encoding]::UTF8.GetString($buf)
}
return $items
}
default { throw "未知 Redis 响应前缀: $prefix" }
}
}
function Get-AvailableRedisPasswords {
$candidates = New-Object System.Collections.Generic.List[string]
foreach ($candidate in @(
$RedisPassword,
'redis_HA_Cluster_2024',
'replace-with-strong-redis-password',
'redis_EsJ8FX'
)) {
if (-not [string]::IsNullOrWhiteSpace($candidate) -and -not $candidates.Contains($candidate)) {
$candidates.Add($candidate)
}
}
return $candidates
}
function Get-RedisValue {
param(
[Parameter(Mandatory = $true)][string]$Key,
[int]$RedisDbIndex = 0
)
$passwords = Get-AvailableRedisPasswords
$lastError = $null
foreach ($passwordCandidate in $passwords) {
$tcp = $null
try {
$tcp = [System.Net.Sockets.TcpClient]::new()
$tcp.ReceiveTimeout = 5000
$tcp.SendTimeout = 5000
$tcp.Connect($RedisHost, $RedisPort)
$stream = $tcp.GetStream()
[void](Send-RedisCommand -Stream $stream -RedisArgs @('AUTH', $passwordCandidate))
if ($RedisDbIndex -gt 0) {
[void](Send-RedisCommand -Stream $stream -RedisArgs @('SELECT', $RedisDbIndex.ToString()))
}
$value = Send-RedisCommand -Stream $stream -RedisArgs @('GET', $Key)
$tcp.Close()
return $value
} catch {
$lastError = $_.Exception.Message
if ($tcp) {
$tcp.Close()
}
}
}
throw "读取 Redis 键失败: $Key;host=${RedisHost}:$RedisPort;lastError=$lastError"
}
function Get-CaptchaCode {
param(
[Parameter(Mandatory = $true)][string]$CaptchaUrl,
[Parameter(Mandatory = $true)][string]$RedisKeyPrefix,
[int]$RedisDb = 0
)
$captchaResp = Invoke-JsonApi -Method GET -Url $CaptchaUrl
if ($captchaResp.HttpStatus -ne 200 -or -not $captchaResp.Json -or -not $captchaResp.Json.data) {
throw "获取验证码失败: $($captchaResp.Raw)"
}
$captchaKey = [string]$captchaResp.Json.data.captchaKey
$code = Get-RedisValue -Key "$RedisKeyPrefix$captchaKey" -RedisDbIndex $RedisDb
return [pscustomobject]@{
CaptchaKey = $captchaKey
CaptchaCode = ([string]$code).Trim('"').Trim()
}
}
function Ensure-ApiSuccess {
param(
[Parameter(Mandatory = $true)]$Response,
[Parameter(Mandatory = $true)][string]$Action
)
if ($Response.HttpStatus -ne 200) {
throw "$Action 失败,HTTP=$($Response.HttpStatus),响应=$($Response.Raw)"
}
if (-not $Response.Json) {
throw "$Action 失败,响应不是 JSON: $($Response.Raw)"
}
if ($Response.Json.code -ne 200) {
throw "$Action 失败,API code=$($Response.Json.code),message=$($Response.Json.message)"
}
}
function New-RandomPhone {
$digits = -join ((0..9) | Get-Random -Count 9)
return "13$digits"
}
function New-Username {
param(
[string]$CategoryName,
[int]$Index
)
$stamp = (Get-Date).ToString('MMddHHmm')
return ("u{0}{1:D2}" -f $stamp, $Index)
}
function Normalize-VideoTitle {
param([string]$FileNameWithoutExtension)
$title = $FileNameWithoutExtension.Trim()
if ($title.Length -lt 5) {
$title = ($title + ' 视频素材').Substring(0, [Math]::Min(($title + ' 视频素材').Length, 100))
}
if ($title.Length -gt 100) {
$title = $title.Substring(0, 100)
}
return $title
}
function Get-UserSession {
param(
[string]$Username,
[string]$Password
)
$captcha = Get-CaptchaCode -CaptchaUrl "$BaseUrl/auth/captcha" -RedisKeyPrefix 'captcha:' -RedisDb 0
$loginResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/auth/login" -Body @{
username = $Username
password = $Password
captcha = $captcha.CaptchaCode
captchaKey = $captcha.CaptchaKey
}
Ensure-ApiSuccess -Response $loginResp -Action "用户登录($Username)"
$accessToken = [string]$loginResp.Json.data.accessToken
$headers = @{ Authorization = "Bearer $accessToken" }
$currentResp = Invoke-JsonApi -Method GET -Url "$BaseUrl/auth/current" -Headers $headers
Ensure-ApiSuccess -Response $currentResp -Action "读取当前用户($Username)"
return [pscustomobject]@{
Username = $Username
Password = $Password
UserId = [long]$currentResp.Json.data.id
Headers = $headers
AccessToken = $accessToken
}
}
function Get-ReusedUsersByCategory {
param([string]$ReportPath)
if (-not (Test-Path -LiteralPath $ReportPath)) {
throw "复用用户失败,报告文件不存在: $ReportPath"
}
$json = Get-Content -LiteralPath $ReportPath -Raw | ConvertFrom-Json -Depth 50
$map = @{}
foreach ($entry in @($json.users)) {
$categoryName = [string]$entry.categoryName
if (-not $map.ContainsKey($categoryName)) {
$map[$categoryName] = New-Object System.Collections.Generic.List[object]
}
$session = Get-UserSession -Username ([string]$entry.username) -Password ([string]$entry.password)
$session | Add-Member -NotePropertyName Nickname -NotePropertyValue ([string]$entry.nickname) -Force
$session | Add-Member -NotePropertyName CategoryName -NotePropertyValue $categoryName -Force
$map[$categoryName].Add($session)
}
return $map
}
function Register-ImportUser {
param(
[string]$CategoryName,
[int]$UserIndex,
[string]$Nickname
)
$username = New-Username -CategoryName $CategoryName -Index $UserIndex
$phone = New-RandomPhone
$email = "$username@example.com"
$captcha = Get-CaptchaCode -CaptchaUrl "$BaseUrl/auth/captcha" -RedisKeyPrefix 'captcha:' -RedisDb 0
$registerResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/auth/register" -Body @{
username = $username
password = $DefaultUserPassword
confirmPassword = $DefaultUserPassword
email = $email
phone = $phone
captcha = $captcha.CaptchaCode
captchaKey = $captcha.CaptchaKey
}
Ensure-ApiSuccess -Response $registerResp -Action "注册用户($username)"
$session = Get-UserSession -Username $username -Password $DefaultUserPassword
$profileResp = Invoke-JsonApi -Method PUT -Url "$BaseUrl/user/profile" -Headers $session.Headers -Body @{
nickname = $Nickname
signature = "批量导入账号 - $CategoryName"
region = '南京'
gender = 0
}
Ensure-ApiSuccess -Response $profileResp -Action "更新资料($username)"
$session | Add-Member -NotePropertyName Nickname -NotePropertyValue $Nickname
$session | Add-Member -NotePropertyName CategoryName -NotePropertyValue $CategoryName
return $session
}
function Get-AdminSession {
$captcha = Get-CaptchaCode -CaptchaUrl "$BaseUrl/admin/auth/captcha" -RedisKeyPrefix 'admin:captcha:' -RedisDb 5
$loginResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/admin/auth/login" -Body @{
username = $AdminUsername
password = $AdminPassword
captcha = $captcha.CaptchaCode
captchaKey = $captcha.CaptchaKey
}
Ensure-ApiSuccess -Response $loginResp -Action '管理员登录'
$token = [string]$loginResp.Json.data.token
$headers = @{ Authorization = "Bearer $token" }
$infoResp = Invoke-JsonApi -Method GET -Url "$BaseUrl/admin/auth/info" -Headers $headers
Ensure-ApiSuccess -Response $infoResp -Action '获取管理员信息'
return [pscustomobject]@{
Headers = $headers
AdminId = [long]$infoResp.Json.data.id
Username = $AdminUsername
}
}
function Get-CategoryMap {
$resp = Invoke-JsonApi -Method GET -Url "$BaseUrl/content/category/list"
Ensure-ApiSuccess -Response $resp -Action '读取分类列表'
$map = @{}
foreach ($item in @($resp.Json.data)) {
$map[[string]$item.name] = $item
}
return $map
}
function Get-VideoFilesForFolder {
param([string]$FolderPath)
$extensions = @('.mp4', '.mov', '.mkv', '.avi', '.flv', '.webm', '.m4v')
return @(Get-ChildItem -LiteralPath $FolderPath -File |
Where-Object { $extensions -contains $_.Extension.ToLowerInvariant() } |
Sort-Object Name)
}
$report = [ordered]@{
generatedAt = (Get-Date).ToString('s')
baseUrl = $BaseUrl
videoRoot = $VideoRoot
outputPath = $OutputPath
userPassword = $DefaultUserPassword
summary = [ordered]@{
categoryCount = 0
expectedUsers = 0
createdUsers = 0
expectedVideos = 0
uploadedVideos = 0
reviewedVideos = 0
failedVideos = 0
}
categories = @()
users = @()
errors = @()
}
if (-not (Test-Path -LiteralPath $VideoRoot)) {
throw "视频根目录不存在: $VideoRoot"
}
$categoryFolders = @(Get-ChildItem -LiteralPath $VideoRoot -Directory | Sort-Object Name)
if ($categoryFolders.Count -ne 5) {
throw "期望在 $VideoRoot 下找到 5 个分类目录,实际找到 $($categoryFolders.Count) 个"
}
$categoryMap = Get-CategoryMap
$adminSession = Get-AdminSession
$reusedUsersByCategory = $null
if ($ReuseExistingUsers) {
$reusedUsersByCategory = Get-ReusedUsersByCategory -ReportPath $OutputPath
$report.users = @()
foreach ($categoryUsers in $reusedUsersByCategory.Values) {
foreach ($user in $categoryUsers) {
$report.users += [ordered]@{
categoryName = $user.CategoryName
username = $user.Username
nickname = $user.Nickname
password = $user.Password
userId = $user.UserId
assignedVideoCount = $null
}
}
}
$report.summary.createdUsers = $report.users.Count
}
Save-Report -Report $report
$report.summary.categoryCount = $categoryFolders.Count
$report.summary.expectedUsers = $categoryFolders.Count * 2
$report.summary.expectedVideos = $categoryFolders.Count * 5
$report.summary.uploadedVideos = 0
$report.summary.reviewedVideos = 0
$report.summary.failedVideos = 0
$report.categories = @()
$report.errors = @()
$globalUserIndex = 1
foreach ($folder in $categoryFolders) {
$categoryName = $folder.Name
if (-not $categoryMap.ContainsKey($categoryName)) {
throw "平台中未找到分类: $categoryName"
}
$videoFiles = Get-VideoFilesForFolder -FolderPath $folder.FullName
if ($videoFiles.Count -ne 5) {
throw "分类 [$categoryName] 期望有 5 个视频,实际找到 $($videoFiles.Count) 个"
}
$platformCategory = $categoryMap[$categoryName]
$categoryReport = [ordered]@{
categoryName = $categoryName
categoryId = [long]$platformCategory.id
folderPath = $folder.FullName
userAssignments = @()
}
$report.categories += $categoryReport
Save-Report -Report $report
$assignmentSpecs = @(
@{ UserLabel = 'A'; VideoIndexes = @(0, 1); VideoCount = 2 },
@{ UserLabel = 'B'; VideoIndexes = @(2, 3, 4); VideoCount = 3 }
)
$reusedCategoryUsers = $null
if ($ReuseExistingUsers) {
if (-not $reusedUsersByCategory.ContainsKey($categoryName) -or $reusedUsersByCategory[$categoryName].Count -ne 2) {
throw "分类 [$categoryName] 没有可复用的 2 个用户"
}
$reusedCategoryUsers = $reusedUsersByCategory[$categoryName].ToArray()
}
$assignmentIndex = 0
foreach ($assignmentSpec in $assignmentSpecs) {
if ($ReuseExistingUsers) {
$userSession = $reusedCategoryUsers[$assignmentIndex]
$nickname = $userSession.Nickname
} else {
$nickname = "$categoryName-导入用户$($assignmentSpec.UserLabel)"
$userSession = Register-ImportUser -CategoryName $categoryName -UserIndex $globalUserIndex -Nickname $nickname
$globalUserIndex++
$report.summary.createdUsers++
$report.users += [ordered]@{
categoryName = $categoryName
username = $userSession.Username
nickname = $nickname
password = $userSession.Password
userId = $userSession.UserId
assignedVideoCount = $assignmentSpec.VideoCount
}
}
$userAssignmentReport = [ordered]@{
userId = $userSession.UserId
username = $userSession.Username
nickname = $nickname
assignedVideoCount = $assignmentSpec.VideoCount
videos = @()
}
foreach ($videoIndex in $assignmentSpec.VideoIndexes) {
$file = $videoFiles[$videoIndex]
$videoReport = [ordered]@{
sourceFile = $file.FullName
fileName = $file.Name
ownerUserId = $userSession.UserId
ownerUsername = $userSession.Username
status = 'processing'
upload = $null
create = $null
review = $null
publicCheck = $null
error = $null
}
try {
$uploadResp = Invoke-UploadApi -Url "$BaseUrl/content/video/upload" -FilePath $file.FullName -Headers $userSession.Headers
Ensure-ApiSuccess -Response $uploadResp -Action "上传视频($($file.Name))"
$videoTitle = Normalize-VideoTitle -FileNameWithoutExtension $file.BaseName
$createResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/content/video" -Headers $userSession.Headers -Body @{
title = $videoTitle
description = "批量导入视频;分类:$categoryName;源文件:$($file.Name)"
categoryId = [long]$platformCategory.id
tagIds = @()
coverUrl = $uploadResp.Json.data.coverUrl
videoUrl = $uploadResp.Json.data.videoUrl
duration = $uploadResp.Json.data.duration
width = $uploadResp.Json.data.width
height = $uploadResp.Json.data.height
format = $uploadResp.Json.data.format
size = $uploadResp.Json.data.size
}
Ensure-ApiSuccess -Response $createResp -Action "创建视频($($file.Name))"
$videoId = [long]$createResp.Json.data
$reviewResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/admin/platform/content/video/review/$videoId?status=1&reason=bulk-import" -Headers $adminSession.Headers
Ensure-ApiSuccess -Response $reviewResp -Action "审核视频($videoId)"
$publicCheckResp = Invoke-JsonApi -Method GET -Url "$BaseUrl/content/video/public/$videoId"
Ensure-ApiSuccess -Response $publicCheckResp -Action "校验公开视频($videoId)"
$videoReport.status = 'completed'
$videoReport.upload = [ordered]@{
videoUrl = $uploadResp.Json.data.videoUrl
coverUrl = $uploadResp.Json.data.coverUrl
duration = $uploadResp.Json.data.duration
width = $uploadResp.Json.data.width
height = $uploadResp.Json.data.height
format = $uploadResp.Json.data.format
size = $uploadResp.Json.data.size
}
$videoReport.create = [ordered]@{
videoId = $videoId
title = $videoTitle
categoryId = [long]$platformCategory.id
}
$videoReport.review = [ordered]@{
status = 1
reviewerAdminId = $adminSession.AdminId
}
$videoReport.publicCheck = [ordered]@{
videoId = $videoId
apiCode = $publicCheckResp.Json.code
}
$report.summary.uploadedVideos++
$report.summary.reviewedVideos++
} catch {
$videoReport.status = 'failed'
$videoReport.error = $_.Exception.Message
$report.summary.failedVideos++
$report.errors += "[$categoryName][$($userSession.Username)][$($file.Name)] $($_.Exception.Message)"
}
$userAssignmentReport.videos += $videoReport
Save-Report -Report $report
}
$categoryReport.userAssignments += $userAssignmentReport
$assignmentIndex++
Save-Report -Report $report
}
Save-Report -Report $report
}
$report.generatedAt = (Get-Date).ToString('s')
Save-Report -Report $report
$report | ConvertTo-Json -Depth 20