chore(tools): 添加短剧资源批量上线脚本

This commit is contained in:
Zhou Weidong committed 2026-10-20 09:11:45 +08:00
1 parent 842e716ea6
commit 6022d789ed
1 file changed
+578
+578
View File
@@ -0,0 +1,578 @@
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)"