diff --git a/tools/publish-drama-assets.ps1 b/tools/publish-drama-assets.ps1 new file mode 100644 index 0000000..7324eb2 --- /dev/null +++ b/tools/publish-drama-assets.ps1 @@ -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}_(?.+)_(?[^_]+)$') + 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)"