Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5468508
Stop guessing TikTok video ids from captions
Oct 10, 2026
a91697b
Harden the TikTok id repair and resolver after review
Oct 10, 2026
c7c0103
Move the TikTok video id lookup into the publisher and drop TikTokAna…
Oct 10, 2026
c37d4b3
Keep the TikTok resolver from failing in a loop
Oct 10, 2026
db65564
Protect TikTok posts without an analytics publication and skip broken…
Oct 10, 2026
606fed0
Load the channel with the posts the repair reads and cover its fallbacks
Oct 10, 2026
5c0c170
Run the TikTok video id sweep every minute
Oct 10, 2026
38f92af
Repair only posts that still have a channel and test the sweep by whe…
Oct 10, 2026
245b118
Ask TikTok for the video id a minute after the publish
Oct 10, 2026
f226210
Test that only TikTok posts ask for a video id after publishing
Oct 10, 2026
243f1a3
Cover the publisher's token handling when reading a video id and an u…
Oct 10, 2026
af24082
Ask only public TikTok posts, keep resolved ids from the importer, fi…
Oct 10, 2026
a026fbc
Retry deadlocks when assigning a TikTok video id and log held videos …
Oct 10, 2026
ee4965e
Leave a TikTok post for the next check when its video is discovered a…
Oct 10, 2026
3efe2d7
Drop a global use statement that broke the suite in a namespaceless t…
Oct 10, 2026
a216557
Settle TikTok videos several posts hold in the repair
Oct 10, 2026
50b1dce
Make the repair's second phase prove ownership and give released post…
Oct 10, 2026
ce0622d
Settle each shared TikTok video with the posts holding it now
Oct 10, 2026
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
1 change: 1 addition & 0 deletions .ai/rules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ Before planning or editing, find the row whose globs match the file's path and r
| app/Enums/SocialAccount/Platform.php | .ai/rules/social-account.md |
| app/Services/Social/GoogleBusinessPublisher.php, app/Support/Social/GoogleBusinessDerivativeCleaner.php, app/Actions/Post/DeletePost.php, app/Actions/Post/UpdatePost.php, app/Actions/Workspace/PurgeWorkspace.php, app/Http/Controllers/Auth/SocialController.php, app/Support/Social/ThreadProgress.php, app/Support/ThreadReplies.php, app/Services/Social/Concerns/PublishesThreads.php, app/Services/Social/FacebookPublisher.php, app/Services/Social/InstagramPublisher.php | .ai/rules/social.md |
| app/Support/PostPlatformMetaRules.php | .ai/rules/support.md |
| app/Services/Social/TikTok*.php, app/Actions/Post/AssignTikTokVideoId.php, app/Jobs/ResolveTikTokVideoId.php, app/Console/Commands/ResolveTikTokVideoIds.php | .ai/rules/tiktok.md |
| app/Mcp/Tools/** | .ai/rules/tools.md |
12 changes: 12 additions & 0 deletions .ai/rules/tiktok.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
paths:
- 'app/Services/Social/TikTok*.php'
- app/Actions/Post/AssignTikTokVideoId.php
- app/Jobs/ResolveTikTokVideoId.php
- app/Console/Commands/ResolveTikTokVideoIds.php
---

# TikTok

## TikTok video ids come only from the status fetch
Never infer a TikTok post's video id from video/list (caption prefix, create_time window): it handed each post of a same-caption series the previous post's video (17 posts, October 2026). TikTok reports `publicaly_available_post_id` only after moderation (minutes to hours, seen weeks later); until then the post keeps its publish_id and the profile url. `PublishToSocialPlatform` dispatches `ResolveTikTokVideoId` (through `TikTokPublisher::publicVideoId()`) a minute after the publish; `social:resolve-tiktok-video-ids` (every 15 minutes) asks again for slower reviews, for 30 days. The resolver and the metrics job write the id through `AssignTikTokVideoId`, which also moves the analytics publication. The only other writer is `ImportExternalPosts::claimedBySentPost` (exact text, single candidate in the match window, deferred when ambiguous); for TikTok it only claims for a post without a numeric video id (still on its publish_id, or released by the repair), never one whose numeric id TikTok already reported. Only posts public to everyone are asked: TikTok never reports an id for followers, friends or private posts. A video another TryPost post holds is refused (`LogicException` from `reconcileRemoteId`); the resolver logs it once a day instead of failing, and `tiktok:repair-video-ids` is the fix for those.
61 changes: 61 additions & 0 deletions app/Actions/Post/AssignTikTokVideoId.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace App\Actions\Post;

use App\Actions\Analytics\SyncTryPostPublication;
use App\Actions\Analytics\UpsertAnalyticsPublication;
use App\Models\Post;
use App\Services\Social\TikTokPublisher;
use Illuminate\Support\Facades\DB;

/**
* Moves a TikTok post from its Content Posting `publish_id` to the public
* video id TikTok reported for it, together with its analytics publication.
* A post published before it had one gets it first, so the move still drops
* an imported copy of the video and refuses a video another post holds.
*/
class AssignTikTokVideoId
{
/**
* The importer locks the publication before the post, this action the post
* before the publication; a rare deadlock between them is retried.
*/
private const int DEADLOCK_ATTEMPTS = 3;

public function __construct(
private readonly SyncTryPostPublication $syncPublication,
private readonly UpsertAnalyticsPublication $publications,
) {}

public function handle(Post $post, string $videoId): void
{
DB::transaction(function () use ($post, $videoId): void {
$locked = Post::query()->lockForUpdate()->find($post->id);

if (blank($locked)) {
return;
}

$account = $locked->socialAccount;
$previousUrl = $locked->platform_url;
$videoUrl = filled($account) ? TikTokPublisher::postUrl($account, $videoId) : null;
$publication = $locked->analyticsPublication()->first()
?? (filled($account) ? $this->syncPublication->handle($locked) : null);

if (filled($publication)) {
$this->publications->reconcileRemoteId($publication, $videoId);

if (filled($videoUrl) && (blank($publication->permalink) || $publication->permalink === $previousUrl)) {
$publication->update(['permalink' => $videoUrl]);
}
}

$locked->writePublication([
'platform_post_id' => $videoId,
'platform_url' => $videoUrl ?? $previousUrl,
]);
}, self::DEADLOCK_ATTEMPTS);
}
}
4 changes: 3 additions & 1 deletion app/Actions/Post/ImportExternalPosts.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ private static function isTryPostThreadSegment(SocialAccount $account, Analytics
* from this channel with the same text near the same time is that
* publication: it takes over the remote id instead of a duplicate import.
* Multiple candidates, captionless Instagram posts and Instagram publishes
* still in progress wait without claiming a remote id.
* still in progress wait without claiming a remote id. A TikTok post whose
* numeric video id TikTok already reported is never a candidate.
*/
private static function claimedBySentPost(SocialAccount $account, AnalyticsPublication $publication): bool
{
Expand Down Expand Up @@ -255,6 +256,7 @@ private static function matchingTryPostTargets(SocialAccount $account, Analytics
->whereColumn('analytics_publications.remote_id', 'posts.platform_post_id')))
->with('analyticsPublication')
->get()
->reject(fn (Post $target): bool => $account->platform === Platform::TikTok && ctype_digit((string) $target->platform_post_id))
->filter(fn (Post $target): bool => self::sameText(
$target->content_type->isCaptionless() ? '' : (string) $target->content,
$text,
Expand Down
66 changes: 66 additions & 0 deletions app/Console/Commands/ResolveTikTokVideoIds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Enums\TikTok\PrivacyLevel;
use App\Jobs\ResolveTikTokVideoId;
use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;

class ResolveTikTokVideoIds extends Command
{
protected $signature = 'social:resolve-tiktok-video-ids';

protected $description = 'Ask TikTok for the video ids of public posts still on a publish_id';

/**
* TikTok has reported a video id weeks after the publish, so the sweep keeps
* asking for a month, less often as the post gets older.
*/
private const int RESOLVE_WITHIN_DAYS = 30;

private const int RECHECK_NEW_POST_AFTER_MINUTES = 10;

private const int RECHECK_FIRST_DAY_AFTER_MINUTES = 60;

private const int RECHECK_OLDER_POST_AFTER_MINUTES = 1440;

public function handle(): int
{
Post::query()
->publishedToTikTok()
->where('meta->privacy_level', PrivacyLevel::PublicToEveryone->value)
->whereHas('socialAccount', fn (Builder $query): Builder => $query->connected())
->where('published_at', '>=', now()->subDays(self::RESOLVE_WITHIN_DAYS))
->select(['id', 'platform', 'origin', 'publish_status', 'platform_post_id', 'meta', 'published_at', 'last_reconciled_at'])
->lazyById()
->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId() && $this->isDue($post))
->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post));

return self::SUCCESS;
}

/**
* The publish job already asks a minute after the publish, so this sweep
* (every fifteen minutes) only catches slower reviews: on every run
* through a post's first hour, hourly through its first day and daily
* after that.
*/
private function isDue(Post $post): bool
{
if (blank($post->last_reconciled_at)) {
return true;
}

$recheckAfterMinutes = match (true) {
$post->published_at->greaterThan(now()->subHour()) => self::RECHECK_NEW_POST_AFTER_MINUTES,
$post->published_at->greaterThan(now()->subDay()) => self::RECHECK_FIRST_DAY_AFTER_MINUTES,
default => self::RECHECK_OLDER_POST_AFTER_MINUTES,
};

return $post->last_reconciled_at->lessThanOrEqualTo(now()->subMinutes($recheckAfterMinutes));
}
}
Loading
Loading