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}_(?.+)_(?[^_]+)$') 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)"