579 lines
33 KiB
PowerShell
579 lines
33 KiB
PowerShell
param(
|
|
[string]$BaseUrl = 'http://127.0.0.1:8080',
|
|
[string]$AppBaseUrl = 'http://127.0.0.1:8081',
|
|
[string]$AssetRoot = 'C:\work\drama_assets',
|
|
[string]$AdminUser = 'admin',
|
|
[string]$AdminPassword = 'admin123',
|
|
[string]$ProgressPath = 'C:/Users/Administrator/Desktop/ReelShorts/FruitServer/docs/plans/PROGRESS.md',
|
|
[string]$StatePath = 'C:/work/ReelShort/docs/plans/fruitshorts-publish-state.json',
|
|
[string]$ReportPath = 'C:/work/ReelShort/docs/plans/fruitshorts-publish-report.json',
|
|
[string]$DbContainer = 'fruitshorts-db',
|
|
[string]$DbUser = 'fruitshorts',
|
|
[string]$DbName = 'fruitshorts',
|
|
[string]$WorkerImage = 'fruitshorts/transcode-worker:latest',
|
|
[int]$TranscodeTimeoutMinutes = 120
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$script:Utf8 = [System.Text.UTF8Encoding]::new($false)
|
|
[Console]::OutputEncoding = $script:Utf8
|
|
$OutputEncoding = $script:Utf8
|
|
Add-Type -AssemblyName System.Net.Http
|
|
|
|
$script:BaseUrl = $BaseUrl.TrimEnd('/')
|
|
$script:AppBaseUrl = $AppBaseUrl.TrimEnd('/')
|
|
$script:AssetRoot = $AssetRoot.TrimEnd('\')
|
|
$script:ProgressPath = [System.IO.Path]::GetFullPath($ProgressPath)
|
|
$script:StatePath = [System.IO.Path]::GetFullPath($StatePath)
|
|
$script:ReportPath = [System.IO.Path]::GetFullPath($ReportPath)
|
|
$script:DbContainer = $DbContainer
|
|
$script:DbUser = $DbUser
|
|
$script:DbName = $DbName
|
|
$script:WorkerImage = $WorkerImage
|
|
|
|
$script:Handler = [System.Net.Http.HttpClientHandler]::new()
|
|
$script:Http = [System.Net.Http.HttpClient]::new($script:Handler)
|
|
$script:Http.Timeout = [TimeSpan]::FromMinutes(15)
|
|
|
|
function Ensure-ParentDirectory {
|
|
param([string]$Path)
|
|
$parent = Split-Path -Parent $Path
|
|
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
|
|
New-Item -ItemType Directory -Force -Path $parent | Out-Null
|
|
}
|
|
}
|
|
|
|
function Protect-Token {
|
|
param([string]$Raw)
|
|
if ([string]::IsNullOrEmpty($Raw)) { return $Raw }
|
|
return [regex]::Replace($Raw, '("token"\s*:\s*")[^"]+(")', '$1***$2')
|
|
}
|
|
|
|
function Write-ProgressLog {
|
|
param([string]$Step, [string]$CommandText, [string]$Raw, [string]$Interpretation)
|
|
$safeRaw = Protect-Token $Raw
|
|
$text = [System.Text.StringBuilder]::new()
|
|
[void]$text.AppendLine("")
|
|
[void]$text.AppendLine("### $([DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss')) - $Step")
|
|
[void]$text.AppendLine("Command:")
|
|
[void]$text.AppendLine('```powershell')
|
|
[void]$text.AppendLine($CommandText)
|
|
[void]$text.AppendLine('```')
|
|
[void]$text.AppendLine("Raw output:")
|
|
[void]$text.AppendLine('```json')
|
|
[void]$text.AppendLine($safeRaw)
|
|
[void]$text.AppendLine('```')
|
|
[void]$text.AppendLine("Interpretation: $Interpretation")
|
|
Ensure-ParentDirectory $script:ProgressPath
|
|
[System.IO.File]::AppendAllText($script:ProgressPath, $text.ToString(), $script:Utf8)
|
|
}
|
|
|
|
function Invoke-ApiRaw {
|
|
param([string]$Method, [string]$Url, [string]$Token, $Body = $null, $Content = $null, [hashtable]$Headers = @{})
|
|
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), $Url)
|
|
if ($Token) {
|
|
$request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token)
|
|
}
|
|
foreach ($key in $Headers.Keys) {
|
|
[void]$request.Headers.TryAddWithoutValidation($key, [string]$Headers[$key])
|
|
}
|
|
if ($null -ne $Content) {
|
|
$request.Content = $Content
|
|
} elseif ($null -ne $Body) {
|
|
$json = $Body | ConvertTo-Json -Depth 20 -Compress
|
|
$request.Content = [System.Net.Http.StringContent]::new($json, $script:Utf8, 'application/json')
|
|
}
|
|
try {
|
|
$response = $script:Http.SendAsync($request).GetAwaiter().GetResult()
|
|
$raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
|
if (-not $response.IsSuccessStatusCode) {
|
|
throw "HTTP $([int]$response.StatusCode) $($response.ReasonPhrase): $raw"
|
|
}
|
|
return $raw
|
|
} finally {
|
|
$request.Dispose()
|
|
}
|
|
}
|
|
|
|
function Invoke-Api {
|
|
param([string]$Method, [string]$Path, [string]$Token, $Body = $null, [string]$Step, [string]$CommandText, [string]$Interpretation, [string]$BaseOverride)
|
|
$base = if ($BaseOverride) { $BaseOverride } else { $script:BaseUrl }
|
|
$url = $base + $Path
|
|
if (-not $CommandText) {
|
|
$CommandText = "$Method $url"
|
|
if ($null -ne $Body) {
|
|
$CommandText += "`nBODY " + ($Body | ConvertTo-Json -Depth 20 -Compress)
|
|
}
|
|
}
|
|
$raw = Invoke-ApiRaw -Method $Method -Url $url -Token $Token -Body $Body
|
|
Write-ProgressLog -Step $Step -CommandText $CommandText -Raw $raw -Interpretation $Interpretation
|
|
$obj = $raw | ConvertFrom-Json
|
|
if ([int]$obj.code -ne 0) {
|
|
throw "API failed at '$Step': code=$($obj.code) message=$($obj.message)"
|
|
}
|
|
return $obj.data
|
|
}
|
|
function ConvertTo-HashtableDeep {
|
|
param($Value)
|
|
if ($null -eq $Value) { return $null }
|
|
if ($Value -is [System.Collections.IDictionary]) {
|
|
$result = @{}
|
|
foreach ($key in $Value.Keys) { $result[[string]$key] = ConvertTo-HashtableDeep $Value[$key] }
|
|
return $result
|
|
}
|
|
if ($Value -is [System.Management.Automation.PSCustomObject]) {
|
|
$result = @{}
|
|
foreach ($property in $Value.PSObject.Properties) { $result[$property.Name] = ConvertTo-HashtableDeep $property.Value }
|
|
return $result
|
|
}
|
|
return $Value
|
|
}
|
|
|
|
function Load-State {
|
|
if (Test-Path -LiteralPath $script:StatePath) {
|
|
$raw = [System.IO.File]::ReadAllText($script:StatePath, $script:Utf8)
|
|
if (-not [string]::IsNullOrWhiteSpace($raw)) {
|
|
return ConvertTo-HashtableDeep ($raw | ConvertFrom-Json)
|
|
}
|
|
}
|
|
return @{ runStartedAt = [DateTime]::Now.ToString('o'); dramas = @{} }
|
|
}
|
|
|
|
function Save-State {
|
|
Ensure-ParentDirectory $script:StatePath
|
|
$json = $script:State | ConvertTo-Json -Depth 30
|
|
[System.IO.File]::WriteAllText($script:StatePath, $json, $script:Utf8)
|
|
}
|
|
|
|
function Invoke-Psql {
|
|
param([string]$Sql, [string]$Step, [string]$Interpretation, [switch]$Log)
|
|
$commandText = "docker exec $script:DbContainer psql -U $script:DbUser -d $script:DbName -A -t -F `"`t`" -c `"$Sql`""
|
|
$output = @(& docker exec $script:DbContainer psql -U $script:DbUser -d $script:DbName -A -t -F "`t" -c $Sql)
|
|
$exitCode = $LASTEXITCODE
|
|
$raw = ($output | Out-String).TrimEnd()
|
|
if ($Log) {
|
|
Write-ProgressLog -Step $Step -CommandText $commandText -Raw $raw -Interpretation $Interpretation
|
|
}
|
|
if ($exitCode -ne 0) { throw "psql failed at '$Step': $raw" }
|
|
return [pscustomobject]@{ Raw = $raw; Lines = $output }
|
|
}
|
|
|
|
function Get-VideoDirectory {
|
|
$candidates = @(Get-ChildItem -LiteralPath $script:AssetRoot -Directory | Where-Object { $_.Name -notmatch '^\d{2}_' })
|
|
foreach ($candidate in $candidates) {
|
|
$firstMp4 = Get-ChildItem -LiteralPath $candidate.FullName -File -Filter '*.mp4' -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($firstMp4) { return $candidate }
|
|
}
|
|
throw "Video directory with mp4 files was not found under $script:AssetRoot"
|
|
}
|
|
|
|
function Get-DramaDirectories {
|
|
return @(Get-ChildItem -LiteralPath $script:AssetRoot -Directory | Where-Object { $_.Name -match '^\d{2}_' } | Sort-Object Name)
|
|
}
|
|
|
|
function Parse-DramaName {
|
|
param([string]$DirectoryName)
|
|
$match = [regex]::Match($DirectoryName, '^\d{2}_(?<slug>.+)_(?<zh>[^_]+)$')
|
|
if (-not $match.Success) { throw "Cannot parse drama directory name: $DirectoryName" }
|
|
$slug = $match.Groups['slug'].Value
|
|
$zh = $match.Groups['zh'].Value
|
|
$words = @($slug -split '_' | Where-Object { $_ })
|
|
$titleWords = @()
|
|
foreach ($word in $words) {
|
|
if ($word -cmatch '^[A-Z]{2,}$' -or $word -match '^(ceo|cto|cfo|coo|cio|ai|tv|vip|mvp)$') { $titleWords += $word.ToUpperInvariant() }
|
|
elseif ($word.Length -gt 0) { $titleWords += $word.Substring(0, 1).ToUpperInvariant() + $word.Substring(1) }
|
|
}
|
|
return [pscustomobject]@{ Title = ($titleWords -join ' '); Chinese = $zh }
|
|
}
|
|
|
|
function Get-VideoDurationMap {
|
|
param([System.IO.DirectoryInfo]$VideoDirectory)
|
|
$map = @{}
|
|
$shell = New-Object -ComObject Shell.Application
|
|
$folder = $shell.Namespace($VideoDirectory.FullName)
|
|
foreach ($file in Get-ChildItem -LiteralPath $VideoDirectory.FullName -File -Filter '*.mp4') {
|
|
$seconds = 0
|
|
try {
|
|
$item = $folder.ParseName($file.Name)
|
|
$durationText = [string]$folder.GetDetailsOf($item, 27)
|
|
$span = [TimeSpan]::Zero
|
|
if ([TimeSpan]::TryParse($durationText, [ref]$span)) { $seconds = [int][Math]::Round($span.TotalSeconds) }
|
|
} catch { $seconds = 0 }
|
|
$map[$file.Name] = $seconds
|
|
}
|
|
$raw = (($map.GetEnumerator() | Sort-Object Name | ForEach-Object { "$($_.Name)`t$($_.Value)" }) -join "`n")
|
|
Write-ProgressLog -Step 'probe video durations' -CommandText 'Windows Shell.Application media metadata (duration column)' -Raw $raw -Interpretation "Read durations for $($map.Count) mp4 files from Windows media metadata."
|
|
return $map
|
|
}
|
|
|
|
function Send-Chunk {
|
|
param([string]$UploadId, [int]$Index, [byte[]]$Bytes, [string]$Token, [string]$FileName)
|
|
$form = [System.Net.Http.MultipartFormDataContent]::new()
|
|
try {
|
|
$content = [System.Net.Http.ByteArrayContent]::new($Bytes)
|
|
$content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream')
|
|
$form.Add($content, 'file', $FileName)
|
|
$url = "$script:BaseUrl/admin/media/upload/chunk?uploadId=$([Uri]::EscapeDataString($UploadId))&index=$Index"
|
|
$raw = Invoke-ApiRaw -Method 'POST' -Url $url -Token $Token -Content $form
|
|
Write-ProgressLog -Step "upload chunk $($Index + 1)" -CommandText "POST $url (multipart file=$FileName, bytes=$($Bytes.Length))" -Raw $raw -Interpretation 'Chunk accepted by the upload session.'
|
|
$obj = $raw | ConvertFrom-Json
|
|
if ([int]$obj.code -ne 0) { throw "Chunk upload failed: $($obj.message)" }
|
|
} finally {
|
|
$form.Dispose()
|
|
}
|
|
}
|
|
|
|
function Upload-Media {
|
|
param([string]$FilePath, [string]$BizType, [string]$Token, [int]$DurationSec = 0)
|
|
$file = Get-Item -LiteralPath $FilePath
|
|
$initBody = @{ fileName = $file.Name; fileSize = $file.Length; bizType = $BizType; chunkSize = 5242880 }
|
|
$init = Invoke-Api -Method 'POST' -Path '/admin/media/upload/init' -Token $Token -Body $initBody -Step "init upload $($file.Name)" -Interpretation "Created upload session for $BizType file $($file.Name)."
|
|
$uploadId = [string]$init.uploadId
|
|
$chunkSize = [int]$init.chunkSize
|
|
$totalChunks = [int]$init.totalChunks
|
|
$stream = [System.IO.File]::OpenRead($FilePath)
|
|
try {
|
|
for ($index = 0; $index -lt $totalChunks; $index++) {
|
|
$remaining = [int64]$file.Length - [int64]$stream.Position
|
|
$length = [int][Math]::Min([int64]$chunkSize, $remaining)
|
|
if ($length -le 0) { throw "Unexpected empty chunk at index $index for $FilePath" }
|
|
$buffer = New-Object byte[] $length
|
|
$read = 0
|
|
while ($read -lt $length) {
|
|
$count = $stream.Read($buffer, $read, $length - $read)
|
|
if ($count -le 0) { break }
|
|
$read += $count
|
|
}
|
|
if ($read -ne $length) { throw "Short read at chunk $index for $FilePath ($read/$length)" }
|
|
Send-Chunk -UploadId $uploadId -Index $index -Bytes $buffer -Token $Token -FileName ("chunk_{0}.bin" -f $index)
|
|
}
|
|
} finally {
|
|
$stream.Dispose()
|
|
}
|
|
$completeBody = @{ uploadId = $uploadId; durationSec = $DurationSec }
|
|
$asset = Invoke-Api -Method 'POST' -Path '/admin/media/upload/complete' -Token $Token -Body $completeBody -Step "complete upload $($file.Name)" -Interpretation "Merged uploaded chunks into media asset $($file.Name)."
|
|
return $asset
|
|
}
|
|
|
|
function Find-ExistingDramas {
|
|
param([string]$Token)
|
|
$page = Invoke-Api -Method 'GET' -Path '/admin/dramas/page?pageIndex=1&pageSize=200' -Token $Token -Step 'list existing dramas' -Interpretation 'Loaded existing dramas for idempotent title matching.'
|
|
$map = @{}
|
|
foreach ($drama in @($page.list)) {
|
|
$title = [string]$drama.title
|
|
if (-not $map.ContainsKey($title)) { $map[$title] = $drama }
|
|
}
|
|
return $map
|
|
}
|
|
|
|
function Get-ExistingEpisodes {
|
|
param([string]$Token, [string]$DramaId)
|
|
$episodes = Invoke-Api -Method 'GET' -Path "/admin/episodes?dramaId=$DramaId" -Token $Token -Step "list episodes drama $DramaId" -Interpretation 'Loaded existing episodes for resume and duplicate prevention.'
|
|
$map = @{}
|
|
foreach ($episode in @($episodes)) { $map[[string]$episode.number] = $episode }
|
|
return $map
|
|
}
|
|
|
|
function Ensure-Published {
|
|
param([string]$Token, [string]$DramaId)
|
|
$drama = Invoke-Api -Method 'GET' -Path "/admin/dramas/$DramaId" -Token $Token -Step "read drama $DramaId status" -Interpretation 'Read current drama status before review transitions.'
|
|
$status = [string]$drama.status
|
|
if ($status -eq 'draft' -or $status -eq 'offline') {
|
|
Invoke-Api -Method 'POST' -Path "/admin/dramas/$DramaId/submit" -Token $Token -Body $null -Step "submit drama $DramaId" -Interpretation 'Drama submitted for review.' | Out-Null
|
|
$status = 'reviewing'
|
|
}
|
|
if ($status -eq 'reviewing') {
|
|
Invoke-Api -Method 'POST' -Path "/admin/dramas/$DramaId/approve" -Token $Token -Body $null -Step "approve drama $DramaId" -Interpretation 'Drama review approved.' | Out-Null
|
|
$status = 'approved'
|
|
}
|
|
if ($status -eq 'approved') {
|
|
Invoke-Api -Method 'POST' -Path "/admin/dramas/$DramaId/publish" -Token $Token -Body $null -Step "publish drama $DramaId" -Interpretation 'Drama published; draft episodes are promoted to published.' | Out-Null
|
|
$status = 'published'
|
|
}
|
|
if ($status -ne 'published') { throw "Drama $DramaId ended in unexpected status: $status" }
|
|
return $status
|
|
}
|
|
function Get-TranscodeSnapshot {
|
|
param([string[]]$AssetIds)
|
|
$inList = ($AssetIds | ForEach-Object { "'$_'" }) -join ','
|
|
$sql = "SELECT media_asset_id,status,retry_count,coalesce(error_msg,'') FROM transcode_task WHERE media_asset_id IN ($inList) ORDER BY media_asset_id;"
|
|
$result = Invoke-Psql -Sql $sql -Step 'transcode status snapshot' -Interpretation 'Read transcode_task status for all uploaded videos.' -Log
|
|
$rows = @()
|
|
foreach ($line in $result.Lines) {
|
|
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
|
$parts = $line -split "`t", 4
|
|
$rows += [pscustomobject]@{
|
|
AssetId = [string]$parts[0]
|
|
Status = [string]$parts[1]
|
|
RetryCount = if ($parts.Count -gt 2) { [string]$parts[2] } else { '' }
|
|
Error = if ($parts.Count -gt 3) { [string]$parts[3] } else { '' }
|
|
}
|
|
}
|
|
return [pscustomobject]@{ Rows = $rows; Raw = $result.Raw; Command = $sql }
|
|
}
|
|
|
|
function Wait-Transcode {
|
|
param([string[]]$AssetIds)
|
|
$deadline = [DateTime]::Now.AddMinutes($TranscodeTimeoutMinutes)
|
|
$lastKey = ''
|
|
$lastLogged = [DateTime]::MinValue
|
|
$retried = $false
|
|
while ($true) {
|
|
$snapshot = Get-TranscodeSnapshot -AssetIds $AssetIds
|
|
$rows = @($snapshot.Rows)
|
|
$queued = @($rows | Where-Object { $_.Status -eq 'QUEUED' }).Count
|
|
$running = @($rows | Where-Object { $_.Status -eq 'RUNNING' }).Count
|
|
$success = @($rows | Where-Object { $_.Status -eq 'SUCCESS' }).Count
|
|
$failedRows = @($rows | Where-Object { $_.Status -eq 'FAILED' })
|
|
$failed = $failedRows.Count
|
|
$foundIds = @($rows | ForEach-Object { $_.AssetId })
|
|
$missing = @($AssetIds | Where-Object { $_ -notin $foundIds })
|
|
$key = "queued=$queued;running=$running;success=$success;failed=$failed;missing=$($missing.Count)"
|
|
Write-Host "TRANSCODE $key"
|
|
if ($key -ne $lastKey -or ([DateTime]::Now - $lastLogged).TotalSeconds -ge 60) {
|
|
$interpretation = "Transcode progress: $success/$($AssetIds.Count) success, $running running, $queued queued, $failed failed, $($missing.Count) missing task rows."
|
|
Write-ProgressLog -Step 'wait for transcode' -CommandText "docker exec $script:DbContainer psql -U $script:DbUser -d $script:DbName -A -t -F `"`t`" -c `"$($snapshot.Command)`"" -Raw $snapshot.Raw -Interpretation $interpretation
|
|
$lastKey = $key
|
|
$lastLogged = [DateTime]::Now
|
|
}
|
|
if ($queued -eq 0 -and $running -eq 0) {
|
|
if ($failed -eq 0 -and $missing.Count -eq 0) { break }
|
|
if (-not $retried -and $failed -gt 0) {
|
|
$failedIds = @($failedRows | ForEach-Object { $_.AssetId })
|
|
$inList = ($failedIds | ForEach-Object { "'$_'" }) -join ','
|
|
$sql = "UPDATE transcode_task SET status='QUEUED', retry_count=0, error_msg='', started_at=NULL, finished_at=NULL WHERE media_asset_id IN ($inList);"
|
|
Invoke-Psql -Sql $sql -Step 'requeue failed transcode tasks' -Interpretation 'Requeued failed transcode tasks once for automatic recovery.' -Log | Out-Null
|
|
$retried = $true
|
|
Start-Sleep -Seconds 5
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if ([DateTime]::Now -gt $deadline) { throw "Transcode did not finish before timeout: $key" }
|
|
Start-Sleep -Seconds 10
|
|
}
|
|
}
|
|
|
|
function Backfill-Links {
|
|
param([object[]]$Mappings)
|
|
$builder = [System.Text.StringBuilder]::new()
|
|
foreach ($mapping in @($Mappings)) {
|
|
foreach ($value in @($mapping.AssetId, $mapping.DramaId, $mapping.EpisodeId)) {
|
|
if ([string]$value -notmatch '^\d+$') { throw "Unsafe numeric id: $value" }
|
|
}
|
|
[void]$builder.AppendLine("UPDATE transcode_task SET drama_id=$($mapping.DramaId), episode_id=$($mapping.EpisodeId) WHERE media_asset_id=$($mapping.AssetId);")
|
|
[void]$builder.AppendLine("UPDATE media_rendition SET drama_id=$($mapping.DramaId), episode_id=$($mapping.EpisodeId) WHERE media_asset_id=$($mapping.AssetId);")
|
|
[void]$builder.AppendLine("UPDATE drama_episode SET video_asset_id=$($mapping.AssetId), hls_url=(SELECT hls_url FROM media_asset WHERE id=$($mapping.AssetId)), duration_sec=(SELECT duration_sec FROM media_asset WHERE id=$($mapping.AssetId)) WHERE id=$($mapping.EpisodeId);")
|
|
}
|
|
if ($builder.Length -eq 0) {
|
|
Write-ProgressLog -Step 'backfill episode media links' -CommandText '(skipped)' -Raw '' -Interpretation 'No successful transcode mapping to backfill.'
|
|
return
|
|
}
|
|
Invoke-Psql -Sql $builder.ToString() -Step 'backfill episode media links' -Interpretation 'Linked successful HLS renditions and transcode tasks back to drama episodes for client playback.' -Log | Out-Null
|
|
}
|
|
|
|
function Get-HttpResource {
|
|
param([string]$Url, [hashtable]$Headers = @{})
|
|
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Url)
|
|
foreach ($key in $Headers.Keys) { [void]$request.Headers.TryAddWithoutValidation($key, [string]$Headers[$key]) }
|
|
try {
|
|
$response = $script:Http.SendAsync($request).GetAwaiter().GetResult()
|
|
$bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
|
|
$contentType = if ($response.Content.Headers.ContentType) { $response.Content.Headers.ContentType.ToString() } else { '' }
|
|
return [pscustomobject]@{ Status = [int]$response.StatusCode; ContentType = $contentType; Bytes = $bytes; Text = [System.Text.Encoding]::UTF8.GetString($bytes) }
|
|
} finally {
|
|
$request.Dispose()
|
|
}
|
|
}
|
|
function Verify-Client {
|
|
param([object[]]$DramaRecords, [string[]]$ExpectedTitles)
|
|
$feedRaw = Invoke-ApiRaw -Method 'GET' -Url "$script:AppBaseUrl/app/home/feed"
|
|
$feed = $feedRaw | ConvertFrom-Json
|
|
$feedTitles = @()
|
|
foreach ($section in @($feed.data.sections)) {
|
|
foreach ($drama in @($section.dramas)) { $feedTitles += [string]$drama.title }
|
|
}
|
|
$feedTitles = @($feedTitles | Select-Object -Unique)
|
|
$missing = @($ExpectedTitles | Where-Object { $_ -notin $feedTitles })
|
|
Write-ProgressLog -Step 'client feed verification' -CommandText "GET $script:AppBaseUrl/app/home/feed" -Raw $feedRaw -Interpretation "Home feed contains $($feedTitles.Count) unique titles; missing expected titles: $(@($missing) -join ', ')."
|
|
if ($missing.Count -gt 0) { throw "Home feed missing dramas: $($missing -join ', ')" }
|
|
|
|
$sampleDrama = @($DramaRecords | Where-Object { $_.Episodes.Count -gt 0 })[0]
|
|
$sampleEpisode = @($sampleDrama.Episodes | Sort-Object Number)[0]
|
|
$playRaw = Invoke-ApiRaw -Method 'GET' -Url "$script:AppBaseUrl/app/episodes/$($sampleEpisode.Id)/play" -Headers @{ 'X-Device-Id' = 'codex-batch-verify' }
|
|
$play = $playRaw | ConvertFrom-Json
|
|
Write-ProgressLog -Step 'client play verification' -CommandText "GET $script:AppBaseUrl/app/episodes/$($sampleEpisode.Id)/play (X-Device-Id: codex-batch-verify)" -Raw $playRaw -Interpretation "Playback ticket for episode $($sampleEpisode.Id) of drama $($sampleDrama.Title)."
|
|
if ($play.data.accessible -ne $true) { throw "Episode $($sampleEpisode.Id) is not accessible." }
|
|
if ([string]::IsNullOrWhiteSpace([string]$play.data.playUrl)) { throw "Episode $($sampleEpisode.Id) returned an empty playUrl." }
|
|
|
|
$masterUrl = [string]$play.data.playUrl
|
|
if (-not $masterUrl.StartsWith('http')) { $masterUrl = $script:AppBaseUrl + $masterUrl }
|
|
$master = Get-HttpResource -Url $masterUrl
|
|
Write-ProgressLog -Step 'fetch HLS master playlist' -CommandText "GET $masterUrl" -Raw ("HTTP $($master.Status) $($master.ContentType)`n" + $master.Text) -Interpretation 'Fetched the signed HLS master playlist returned by the play API.'
|
|
if ($master.Status -ne 200) { throw "HLS master returned HTTP $($master.Status)." }
|
|
|
|
$variantPath = @($master.Text -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })[0]
|
|
$variantUrl = [Uri]::new([Uri]$masterUrl, $variantPath).AbsoluteUri
|
|
$variant = Get-HttpResource -Url $variantUrl
|
|
Write-ProgressLog -Step 'fetch HLS variant playlist' -CommandText "GET $variantUrl" -Raw ("HTTP $($variant.Status) $($variant.ContentType)`n" + $variant.Text) -Interpretation 'Fetched the first variant playlist referenced by the master playlist.'
|
|
if ($variant.Status -ne 200) { throw "HLS variant returned HTTP $($variant.Status)." }
|
|
|
|
$segmentPath = @($variant.Text -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })[0]
|
|
$segmentUrl = [Uri]::new([Uri]$variantUrl, $segmentPath).AbsoluteUri
|
|
$segment = Get-HttpResource -Url $segmentUrl -Headers @{ Range = 'bytes=0-1023' }
|
|
Write-ProgressLog -Step 'fetch HLS media segment' -CommandText "GET $segmentUrl (Range: bytes=0-1023)" -Raw ("HTTP $($segment.Status) $($segment.ContentType) bytes=$($segment.Bytes.Length)") -Interpretation 'Fetched the first media segment bytes, proving the HLS path is playable.'
|
|
if (($segment.Status -ne 200 -and $segment.Status -ne 206) -or $segment.Bytes.Length -eq 0) { throw "HLS segment returned HTTP $($segment.Status) with $($segment.Bytes.Length) bytes." }
|
|
return [pscustomobject]@{ EpisodeId = $sampleEpisode.Id; MasterUrl = $masterUrl; FeedTitleCount = $feedTitles.Count }
|
|
}
|
|
Ensure-ParentDirectory $script:ProgressPath
|
|
Ensure-ParentDirectory $script:StatePath
|
|
Ensure-ParentDirectory $script:ReportPath
|
|
|
|
Write-ProgressLog -Step 'batch publish started' -CommandText "powershell -File $PSCommandPath" -Raw ("AssetRoot=$script:AssetRoot`nBaseUrl=$script:BaseUrl`nAppBaseUrl=$script:AppBaseUrl`nStatePath=$script:StatePath") -Interpretation 'Starting the full batch publish workflow.'
|
|
|
|
$loginCommand = "POST $script:BaseUrl/admin/auth/login`nBODY {username:$AdminUser,password:***}"
|
|
$loginRaw = Invoke-ApiRaw -Method 'POST' -Url "$script:BaseUrl/admin/auth/login" -Body @{ username = $AdminUser; password = $AdminPassword }
|
|
Write-ProgressLog -Step 'admin login' -CommandText $loginCommand -Raw $loginRaw -Interpretation 'Authenticated as admin and received a bearer token.'
|
|
$login = $loginRaw | ConvertFrom-Json
|
|
if ([int]$login.code -ne 0) { throw "Login failed: $($login.message)" }
|
|
$token = [string]$login.data.token
|
|
if ([string]::IsNullOrWhiteSpace($token)) { throw 'Login returned an empty token.' }
|
|
|
|
$script:State = Load-State
|
|
$existingDramas = Find-ExistingDramas -Token $token
|
|
$videoDirectory = Get-VideoDirectory
|
|
$videoFiles = @(Get-ChildItem -LiteralPath $videoDirectory.FullName -File -Filter '*.mp4' | Sort-Object { [int]$_.BaseName })
|
|
if ($videoFiles.Count -ne 45) { throw "Expected 45 mp4 files, found $($videoFiles.Count)." }
|
|
$durationMap = Get-VideoDurationMap -VideoDirectory $videoDirectory
|
|
$dramaDirectories = Get-DramaDirectories
|
|
if ($dramaDirectories.Count -ne 10) { throw "Expected 10 drama directories, found $($dramaDirectories.Count)." }
|
|
|
|
$episodeCounts = @(5, 4, 5, 4, 5, 4, 5, 4, 5, 4)
|
|
$categoryPlan = @(
|
|
@{ CategoryId = 1; Genre = 'Urban Romance'; IsHot = $true },
|
|
@{ CategoryId = 4; Genre = 'Revenge Suspense'; IsHot = $true },
|
|
@{ CategoryId = 2; Genre = 'Rebirth Comeback'; IsHot = $true },
|
|
@{ CategoryId = 3; Genre = 'Sweet Romance'; IsHot = $false },
|
|
@{ CategoryId = 3; Genre = 'Sweet Romance'; IsHot = $false },
|
|
@{ CategoryId = 1; Genre = 'Urban Romance'; IsHot = $false },
|
|
@{ CategoryId = 2; Genre = 'Rebirth Comeback'; IsHot = $false },
|
|
@{ CategoryId = 1; Genre = 'Urban Romance'; IsHot = $false },
|
|
@{ CategoryId = 4; Genre = 'Revenge Suspense'; IsHot = $false },
|
|
@{ CategoryId = 3; Genre = 'Sweet Romance'; IsHot = $false }
|
|
)
|
|
|
|
$videoOffset = 0
|
|
$dramaRecords = @()
|
|
$successMappings = @()
|
|
for ($dramaIndex = 0; $dramaIndex -lt $dramaDirectories.Count; $dramaIndex++) {
|
|
$directory = $dramaDirectories[$dramaIndex]
|
|
$parsed = Parse-DramaName -DirectoryName $directory.Name
|
|
$title = $parsed.Title
|
|
$chinese = $parsed.Chinese
|
|
$count = $episodeCounts[$dramaIndex]
|
|
$dramaVideos = @($videoFiles[$videoOffset..($videoOffset + $count - 1)])
|
|
$videoOffset += $count
|
|
Write-Host "DRAMA $($dramaIndex + 1)/10 $title ($count episodes)"
|
|
|
|
if (-not $script:State.dramas.ContainsKey($directory.Name)) {
|
|
$script:State.dramas[$directory.Name] = @{ title = $title; chinese = $chinese; coverUrl = ''; coverAssetId = ''; dramaId = ''; status = ''; episodes = @{} }
|
|
}
|
|
$record = $script:State.dramas[$directory.Name]
|
|
|
|
if ([string]::IsNullOrWhiteSpace([string]$record.coverUrl)) {
|
|
$coverFile = Get-ChildItem -LiteralPath $directory.FullName -File | Where-Object { $_.Name -like '*9x16*' } | Select-Object -First 1
|
|
if (-not $coverFile) { throw "Vertical 9x16 cover not found in $($directory.FullName)" }
|
|
$coverAsset = Upload-Media -FilePath $coverFile.FullName -BizType 'DRAMA_COVER' -Token $token
|
|
$record.coverUrl = [string]$coverAsset.url
|
|
$record.coverAssetId = [string]$coverAsset.id
|
|
Save-State
|
|
}
|
|
|
|
$episodeAssets = @()
|
|
for ($localIndex = 0; $localIndex -lt $dramaVideos.Count; $localIndex++) {
|
|
$number = $localIndex + 1
|
|
$videoFile = $dramaVideos[$localIndex]
|
|
$durationSec = 0
|
|
if ($durationMap.ContainsKey($videoFile.Name)) { $durationSec = [int]$durationMap[$videoFile.Name] }
|
|
$key = [string]$number
|
|
if (-not $record.episodes.ContainsKey($key) -or [string]::IsNullOrWhiteSpace([string]$record.episodes[$key].videoUrl)) {
|
|
Write-Host " upload episode $number $($videoFile.Name)"
|
|
$asset = Upload-Media -FilePath $videoFile.FullName -BizType 'EPISODE' -Token $token -DurationSec $durationSec
|
|
$record.episodes[$key] = @{ number = $number; assetId = [string]$asset.id; videoUrl = [string]$asset.url; objectKey = [string]$asset.objectKey; durationSec = $durationSec; id = '' }
|
|
Save-State
|
|
}
|
|
$episodeAssets += [pscustomobject]@{ Number = $number; AssetId = [string]$record.episodes[$key].assetId; VideoUrl = [string]$record.episodes[$key].videoUrl; DurationSec = $durationSec; ObjectKey = [string]$record.episodes[$key].objectKey }
|
|
}
|
|
$existingDrama = $null
|
|
if ($existingDramas.ContainsKey($title)) { $existingDrama = $existingDramas[$title] }
|
|
if (-not [string]::IsNullOrWhiteSpace([string]$record.dramaId)) {
|
|
$dramaId = [string]$record.dramaId
|
|
} elseif ($existingDrama) {
|
|
$dramaId = [string]$existingDrama.id
|
|
$record.dramaId = $dramaId
|
|
$record.status = [string]$existingDrama.status
|
|
Save-State
|
|
} else {
|
|
$plan = $categoryPlan[$dramaIndex]
|
|
$createBody = @{ title = $title; subtitle = $chinese; categoryId = $plan.CategoryId; genre = $plan.Genre; description = $chinese; cover = [string]$record.coverUrl; language = 'id-ID'; freeEpisodes = $count; unlockCoin = 10; isHot = [bool]$plan.IsHot; sort = 100 - $dramaIndex }
|
|
$dramaId = [string](Invoke-Api -Method 'POST' -Path '/admin/dramas' -Token $token -Body $createBody -Step "create drama $title" -Interpretation "Created drama '$title' with Chinese synopsis '$chinese'.")
|
|
$record.dramaId = $dramaId
|
|
$record.status = 'draft'
|
|
$existingDramas[$title] = [pscustomobject]@{ id = $dramaId; title = $title; status = 'draft' }
|
|
Save-State
|
|
}
|
|
|
|
$currentDrama = Invoke-Api -Method 'GET' -Path "/admin/dramas/$dramaId" -Token $token -Step "read drama $dramaId before episodes" -Interpretation 'Read drama metadata before creating or updating episodes.'
|
|
$currentStatus = [string]$currentDrama.status
|
|
$existingEpisodes = Get-ExistingEpisodes -Token $token -DramaId $dramaId
|
|
|
|
foreach ($asset in $episodeAssets) {
|
|
$key = [string]$asset.Number
|
|
$episodeBody = @{ dramaId = $dramaId; number = $asset.Number; title = "Episode $($asset.Number)"; videoUrl = $asset.VideoUrl; durationSec = $asset.DurationSec; isFree = $true; unlockCoin = 10; sort = $asset.Number }
|
|
if ($currentStatus -eq 'published') { $episodeBody.status = 'published' }
|
|
if ($existingEpisodes.ContainsKey($key)) {
|
|
$episodeId = [string]$existingEpisodes[$key].id
|
|
Invoke-Api -Method 'PUT' -Path "/admin/episodes/$episodeId" -Token $token -Body $episodeBody -Step "update episode $episodeId" -Interpretation "Updated episode $($asset.Number) to free playback with unlockCoin=10." | Out-Null
|
|
} else {
|
|
$episodeId = [string](Invoke-Api -Method 'POST' -Path '/admin/episodes' -Token $token -Body $episodeBody -Step "create episode $($asset.Number) for $title" -Interpretation "Created episode $($asset.Number) for drama $title.")
|
|
}
|
|
$record.episodes[$key].id = $episodeId
|
|
$record.episodes[$key].durationSec = $asset.DurationSec
|
|
Save-State
|
|
}
|
|
|
|
$finalStatus = Ensure-Published -Token $token -DramaId $dramaId
|
|
$record.status = $finalStatus
|
|
Save-State
|
|
|
|
$episodeRecords = @()
|
|
foreach ($key in $record.episodes.Keys) {
|
|
$episode = $record.episodes[$key]
|
|
$episodeRecords += [pscustomobject]@{ Number = [int]$episode.number; Id = [string]$episode.id; AssetId = [string]$episode.assetId; VideoUrl = [string]$episode.videoUrl; DurationSec = [int]$episode.durationSec }
|
|
}
|
|
$episodeRecords = @($episodeRecords | Sort-Object Number)
|
|
$dramaRecords += [pscustomobject]@{ Directory = $directory.Name; Title = $title; Chinese = $chinese; DramaId = $dramaId; CoverUrl = [string]$record.coverUrl; Status = $finalStatus; Episodes = $episodeRecords }
|
|
foreach ($episode in $episodeRecords) {
|
|
$successMappings += [pscustomobject]@{ AssetId = $episode.AssetId; DramaId = $dramaId; EpisodeId = $episode.Id }
|
|
}
|
|
}
|
|
|
|
Save-State
|
|
Write-Host "All 10 dramas published. Waiting for transcode..."
|
|
$assetIds = @($successMappings | ForEach-Object { $_.AssetId })
|
|
Wait-Transcode -AssetIds $assetIds
|
|
Backfill-Links -Mappings $successMappings
|
|
$verification = Verify-Client -DramaRecords $dramaRecords -ExpectedTitles @($dramaRecords | ForEach-Object { $_.Title })
|
|
|
|
$idList = ($dramaRecords | ForEach-Object { "'$($_.DramaId)'" }) -join ','
|
|
$summarySql = "SELECT d.id,d.title,d.status,count(e.id),count(*) FILTER (WHERE e.status='published'),count(*) FILTER (WHERE coalesce(e.hls_url,'')<>'') FROM drama d LEFT JOIN drama_episode e ON e.drama_id=d.id WHERE d.id IN ($idList) GROUP BY d.id,d.title,d.status ORDER BY d.title;"
|
|
$summary = Invoke-Psql -Sql $summarySql -Step 'final database summary' -Interpretation 'Final counts of episodes, published episodes and HLS-linked episodes for the 10 imported dramas.' -Log
|
|
Write-ProgressLog -Step 'final drama summary' -CommandText '(derived from state)' -Raw (($dramaRecords | ConvertTo-Json -Depth 20)) -Interpretation 'Final imported drama records including ids, covers, statuses and episode mappings.'
|
|
|
|
$report = @{ finishedAt = [DateTime]::Now.ToString('o'); dramas = $dramaRecords; verification = $verification; databaseSummary = $summary.Raw; statePath = $script:StatePath }
|
|
Ensure-ParentDirectory $script:ReportPath
|
|
[System.IO.File]::WriteAllText($script:ReportPath, ($report | ConvertTo-Json -Depth 30), $script:Utf8)
|
|
|
|
Write-ProgressLog -Step 'batch publish completed' -CommandText 'script end' -Raw ("dramas=$($dramaRecords.Count)`nepisodes=$($assetIds.Count)`nsampleEpisode=$($verification.EpisodeId)`nfeedTitleCount=$($verification.FeedTitleCount)") -Interpretation "All import steps completed; client feed and HLS playback verified. Report: $script:ReportPath"
|
|
Write-Host "BATCH_PUBLISH_OK dramas=$($dramaRecords.Count) episodes=$($assetIds.Count) sampleEpisode=$($verification.EpisodeId)"
|