diff --git a/src/Entities/Tus.php b/src/Entities/Tus.php new file mode 100644 index 0000000..a5134da --- /dev/null +++ b/src/Entities/Tus.php @@ -0,0 +1,164 @@ + $filename], + $metadata + ); + + if ($clipId) { + $tusMetadata['clipId'] = (string) $clipId; + } + + $headers = [ + 'Upload-Length' => (string) $filesize, + 'Upload-Metadata' => self::encodeTusMetadata($tusMetadata), + 'Tus-Resumable' => self::TUS_VERSION, + 'Content-Type' => 'application/offset+octet-stream', + ]; + + return $this->sdk->sendRequestAsync( + new Request('POST', '/sapi/tus', '', $headers) + ); + } + + /** + * Get the current upload status (offset and remaining presigned URLs). + * + * @param string $uploadId The upload ID returned by initUpload. + * + * @return PromiseInterface Resolves to Response with offset and presigned URLs. + */ + public function getStatusAsync(string $uploadId): PromiseInterface + { + return $this->sdk->sendRequestAsync( + new Request('HEAD', '/sapi/tus/' . urlencode($uploadId), '', [ + 'Tus-Resumable' => self::TUS_VERSION, + ]) + ); + } + + /** + * Complete the S3 multipart upload. + * + * Call this after all parts have been uploaded to S3 via presigned URLs. + * + * @param string $uploadId The upload ID. + * @param array[] $parts Array of completed parts with ETag and PartNumber. + * Format: [['ETag' => '"abc..."', 'PartNumber' => 1], ...] + * + * @return PromiseInterface Resolves to Response confirming completion. + */ + public function completeAsync(string $uploadId, array $parts = []): PromiseInterface + { + return $this->sdk->sendRequestAsync( + new Request('POST', '/sapi/tus/' . urlencode($uploadId) . '/complete'), + [ + RequestOptions::JSON => ['parts' => $parts], + ] + ); + } + + /** + * Abort (terminate) an in-progress upload. + * + * Cleans up the S3 multipart upload and removes tracking state. + * + * @param string $uploadId The upload ID to abort. + * + * @return PromiseInterface Resolves to Response confirming termination. + */ + public function abortAsync(string $uploadId): PromiseInterface + { + return $this->sdk->sendRequestAsync( + new Request('DELETE', '/sapi/tus/' . urlencode($uploadId), '', [ + 'Tus-Resumable' => self::TUS_VERSION, + ]) + ); + } + + /** + * Get a presigned URL for a specific part. + * + * Use this to get a fresh presigned URL for a part that needs to be + * re-uploaded (e.g., after a failed upload or expired URL). + * + * @param string $uploadId The upload ID. + * @param int $partNumber The part number (1-based). + * + * @return PromiseInterface Resolves to Response with presigned URL. + */ + public function signPartAsync(string $uploadId, int $partNumber): PromiseInterface + { + return $this->sdk->sendRequestAsync( + new Request('GET', '/sapi/tus/' . urlencode($uploadId) . '/sign/' . $partNumber) + ); + } + + /** + * Encode key-value pairs into TUS Upload-Metadata header format. + * + * @param array $metadata Key-value pairs. + * @return string Encoded header value (key base64value,key base64value,...). + */ + private static function encodeTusMetadata(array $metadata): string + { + $parts = []; + foreach ($metadata as $key => $value) { + $parts[] = $key . ' ' . base64_encode($value); + } + return implode(',', $parts); + } +} diff --git a/src/Sdk.php b/src/Sdk.php index d69c28d..7aacceb 100644 --- a/src/Sdk.php +++ b/src/Sdk.php @@ -23,6 +23,7 @@ * @property-read \BlueBillywig\Entities\Playlist $playlist * @property-read \BlueBillywig\Entities\Playout $playout * @property-read \BlueBillywig\Entities\Subtitle $subtitle + * @property-read \BlueBillywig\Entities\Tus $tus * @method Response sendRequest(Request $request, array $options = []) Send a synchronous request. @see sendRequestAsync * @method array getPublicationData() Retrieve publication data. @see getPublicationDataAsync */ @@ -39,7 +40,8 @@ class Sdk extends EntityRegister [\BlueBillywig\Entities\Channel::class], [\BlueBillywig\Entities\Playlist::class], [\BlueBillywig\Entities\Playout::class], - [\BlueBillywig\Entities\Subtitle::class] + [\BlueBillywig\Entities\Subtitle::class], + ['tus' => \BlueBillywig\Entities\Tus::class], ]; private readonly GuzzleClient $guzzleClient;