Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/DataTransferObjects/MediaItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
class MediaItem
{
/**
* @param array<string, mixed>|null $meta
* @param array<string, mixed>|null $source_meta
*/
public function __construct(
Expand All @@ -20,6 +21,7 @@ public function __construct(
public readonly ?string $original_filename = null,
public readonly ?Source $source = null,
public readonly ?array $source_meta = null,
public readonly ?array $meta = null,
) {}

public function isVideo(): bool
Expand All @@ -37,6 +39,26 @@ public function isDocument(): bool
return Type::classify($this->mime_type, $this->path) === Type::Document;
}

/**
* Stored pixel width from upload-time metadata, when known.
*/
public function width(): ?int
{
$width = data_get($this->meta, 'width');

return is_numeric($width) ? (int) $width : null;
}

/**
* Stored pixel height from upload-time metadata, when known.
*/
public function height(): ?int
{
$height = data_get($this->meta, 'height');

return is_numeric($height) ? (int) $height : null;
}

/**
* @param array<string, mixed> $data
*/
Expand All @@ -63,6 +85,7 @@ public static function fromArray(array $data): self
$source = is_string($sourceValue) ? Source::tryFrom($sourceValue) : null;

$sourceMeta = data_get($data, 'source_meta');
$meta = data_get($data, 'meta');

return new self(
id: data_get($data, 'id', ''),
Expand All @@ -72,6 +95,7 @@ public static function fromArray(array $data): self
original_filename: data_get($data, 'original_filename'),
source: $source,
source_meta: is_array($sourceMeta) ? $sourceMeta : null,
meta: is_array($meta) ? $meta : null,
);
}
}
12 changes: 12 additions & 0 deletions app/Services/Media/MediaOptimizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ public function optimizeImage(string $filePath, Platform $platform): string
return $tempFile;
}

/**
* The maximum image width (px) enforced for a platform. Pull-from-URL
* publishers (e.g. TikTok) use this to decide whether a source image needs
* a resized, spec-compliant derivative before the platform fetches it.
*/
public function maxWidthForPlatform(Platform $platform): ?int
{
$maxWidth = data_get($this->getImageConfig($platform), 'max_width');

return is_int($maxWidth) ? $maxWidth : null;
}

/**
* Center-crop an image to the given aspect ratio (width / height).
* Returns path to a temp file (caller must clean up).
Expand Down
204 changes: 165 additions & 39 deletions app/Services/Social/TikTokPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,28 @@

namespace App\Services\Social;

use App\DataTransferObjects\MediaItem;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;

class TikTokPublisher
{
use HasSocialHttpClient;

private const PHOTO_DERIVATIVE_DIRECTORY = 'social-tiktok-photos';

private string $baseUrl;

private string $accessToken;
Expand Down Expand Up @@ -198,66 +206,184 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte

private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array
{
$photoUrls = $mediaCollection
->filter(fn ($m) => $m->isImage())
->map(fn ($m) => $m->url)
->values()
->toArray();
$images = $mediaCollection->filter(fn ($m) => $m->isImage())->values();

if (empty($photoUrls)) {
if ($images->isEmpty()) {
throw new TikTokPublishException(
userMessage: 'No valid images found for TikTok photo post',
category: ErrorCategory::MediaFormat,
);
}

$postInfo = $this->buildPhotoPostInfo($postPlatform, $content);
$derivatives = [];

// Auto add music is only for photos.
$meta = $postPlatform->meta ?? [];
if (data_get($meta, 'auto_add_music', false)) {
$postInfo['auto_add_music'] = true;
try {
$photoUrls = [];

foreach ($images as $image) {
[$url, $derivativePath] = $this->resolvePhotoUrl($image);
$photoUrls[] = $url;

if ($derivativePath !== null) {
$derivatives[] = $derivativePath;
}
}

$postInfo = $this->buildPhotoPostInfo($postPlatform, $content);

// Auto add music is only for photos.
$meta = $postPlatform->meta ?? [];
if (data_get($meta, 'auto_add_music', false)) {
$postInfo['auto_add_music'] = true;
}

$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/content/init/", [
'post_info' => $postInfo,
'source_info' => [
'source' => 'PULL_FROM_URL',
'photo_cover_index' => 0,
'photo_images' => $photoUrls,
],
'post_mode' => 'DIRECT_POST',
'media_type' => 'PHOTO',
]);

if ($response->failed()) {
Log::error('TikTok photo publish failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}

$data = $response->json();

$publishId = data_get($data, 'data.publish_id');

if (! $publishId) {
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
category: ErrorCategory::ServerError,
);
}

// Wait for processing and get final status
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');

return [
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
} finally {
$this->pruneDerivatives($derivatives);
}
}

$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/content/init/", [
'post_info' => $postInfo,
'source_info' => [
'source' => 'PULL_FROM_URL',
'photo_cover_index' => 0,
'photo_images' => $photoUrls,
],
'post_mode' => 'DIRECT_POST',
'media_type' => 'PHOTO',
]);
/**
* Resolve the URL TikTok will PULL_FROM_URL for a single photo. TikTok rejects
* images wider than 1080px with picture_size_check_failed, and because the
* platform fetches the bytes from us we cannot optimize them in-flight like
* the upload-based publishers do. So an oversized image is rendered to a
* spec-compliant JPEG derivative hosted on our public disk and that URL is
* handed to TikTok instead. Images already within spec pass through untouched.
*
* @return array{0: string, 1: string|null} the URL to publish, and the
* storage path of any derivative
* created (null when passed through)
*/
private function resolvePhotoUrl(MediaItem $image): array
{
$maxWidth = app(MediaOptimizer::class)->maxWidthForPlatform(Platform::TikTok);
$width = $image->width();

if ($response->failed()) {
Log::error('TikTok photo publish failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
if ($maxWidth !== null && $width !== null && $width <= $maxWidth) {
return [$image->url, null];
}

$data = $response->json();
return $this->renderCompliantPhoto($image);
}

$publishId = data_get($data, 'data.publish_id');
/**
* Download the image, resize it to TikTok's spec, and host the copy on the
* public disk so TikTok can pull it.
*
* @return array{0: string, 1: string} the derivative's public URL and its
* storage path (for later cleanup)
*/
private function renderCompliantPhoto(MediaItem $image): array
{
$tempInput = tempnam(sys_get_temp_dir(), 'tiktok_photo_');

try {
$download = Http::sink($tempInput)->timeout(120)->get($image->url);

if ($download->failed()) {
throw new TikTokPublishException(
userMessage: 'Failed to download image for TikTok resizing',
category: ErrorCategory::ServerError,
);
}

return $this->hostResizedPhoto($tempInput);
} finally {
@unlink($tempInput);
}
}

/**
* Resize the downloaded file to TikTok's spec and host the copy on the public
* disk. Decoder/storage failures are surfaced as a categorized publish
* exception instead of leaking as an uncategorized error.
*
* @return array{0: string, 1: string} the derivative's public URL and storage path
*/
private function hostResizedPhoto(string $tempInput): array
{
try {
$optimized = app(MediaOptimizer::class)->optimizeImage($tempInput, Platform::TikTok);

try {
$path = self::PHOTO_DERIVATIVE_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($optimized));
} finally {
@unlink($optimized);
}

return [Storage::url($path), $path];
} catch (Throwable $e) {
Log::error('TikTok photo resize/host failed', [
'exception' => $e->getMessage(),
]);

if (! $publishId) {
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
userMessage: 'Failed to prepare image for TikTok.',
category: ErrorCategory::ServerError,
);
}
}

// Wait for processing and get final status
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');
/**
* Remove hosted photo derivatives, swallowing storage errors so cleanup can
* never mask the publish result.
*
* @param list<string> $paths
*/
private function pruneDerivatives(array $paths): void
{
if ($paths === []) {
return;
}

return [
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
try {
Storage::delete($paths);
} catch (Throwable $e) {
Log::warning('Failed to prune TikTok photo derivatives', [
'paths' => $paths,
'exception' => $e->getMessage(),
]);
}
}

private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): array
Expand Down
10 changes: 6 additions & 4 deletions tests/Feature/Services/Social/TikTokCreatorInfoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
]);

$this->service = new TikTokCreatorInfo;

$this->api = config('trypost.platforms.tiktok.api');
});

test('it returns full creator payload from api response', function () {
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => [
'creator_nickname' => 'Paulo',
'creator_username' => 'paulocastellano',
Expand Down Expand Up @@ -49,7 +51,7 @@

test('it returns an empty payload when the api fails', function () {
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response(['error' => 'unauthorized'], 401),
$this->api.'/post/publish/creator_info/query/' => Http::response(['error' => 'unauthorized'], 401),
]);

$info = $this->service->fetch($this->account);
Expand All @@ -66,12 +68,12 @@
$this->account->update(['token_expires_at' => now()->subMinute()]);

Http::fake([
'https://open.tiktokapis.com/v2/oauth/token/' => Http::response([
$this->api.'/oauth/token/' => Http::response([
'access_token' => 'new-token',
'refresh_token' => 'new-refresh',
'expires_in' => 3600,
], 200),
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['PUBLIC_TO_EVERYONE'],
],
Expand Down
Loading
Loading