📄
src/Infrastructure/TikTok/TikTokUploader.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
using System; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Slopper.Domain; namespace Slopper.Infrastructure.TikTok; internal sealed class TikTokUploader( ITikTokAccessTokenProvider accessTokenProvider, IOptions<TikTokUploaderOptions> options, IHttpClientFactory httpClientFactory, TimeProvider timeProvider ) : IUploader { private static readonly Uri TikTokBase = new("https://open.tiktokapis.com"); private readonly long defaultChunkSize = options.Value.ChunkSize; private readonly string privacyLevel = options.Value.PrivacyLevel; public async Task<Upload> Upload(Clip clip, DateTimeOffset publishAt, CancellationToken cancellationToken) { using var activity = Tracing.StartUpload(clip.Id); var token = accessTokenProvider.AccessToken; var client = httpClientFactory.CreateClient("TikTok"); var (publishId, uploadUrl) = await Init(client, token, clip, cancellationToken); await UploadFile(client, uploadUrl, clip.Path, cancellationToken); return await PollStatus(client, token, publishId, cancellationToken); } private async Task<(string PublishId, string UploadUrl)> Init( HttpClient client, string token, Clip clip, CancellationToken cancellationToken ) { var fileSize = new FileInfo(clip.Path).Length; var chunkSize = long.Min(defaultChunkSize, fileSize); var chunkCount = (long)double.Ceiling((double)fileSize / chunkSize); var body = new InitRequest( new PostInfo(clip.Caption ?? string.Empty, privacyLevel), new SourceInfo(fileSize, chunkSize, chunkCount) ); using var request = new HttpRequestMessage( HttpMethod.Post, new Uri(TikTokBase, "/v2/post/publish/video/init/") ); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Content = JsonContent.Create(body); using var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync<TikTokResponse<InitData>>(cancellationToken) ?? throw new Exception("Failed to deserialize TikTok init response"); if (result.Error.Code is not "ok") throw new Exception($"TikTok init failed: {result.Error.Code} — {result.Error.Message}"); return (result.Data.PublishId, result.Data.UploadUrl); } private async Task UploadFile(HttpClient client, string uploadUrl, string path, CancellationToken cancellationToken) { var fileSize = new FileInfo(path).Length; var chunkSize = long.Min(defaultChunkSize, fileSize); var chunkCount = (long)double.Ceiling((double)fileSize / chunkSize); using var stream = File.OpenRead(path); if (chunkCount is 1) { var content = new StreamContent(stream); content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4"); content.Headers.TryAddWithoutValidation("Content-Range", $"bytes 0-{fileSize - 1}/{fileSize}"); using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { Content = content }; using var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); return; } var buffer = new byte[chunkSize]; for (var i = 0; i < chunkCount; i++) { var start = i * chunkSize; var thisChunkSize = (int)long.Min(chunkSize, fileSize - start); var end = start + thisChunkSize - 1; await stream.ReadExactlyAsync(buffer.AsMemory(0, thisChunkSize), cancellationToken); var content = new ByteArrayContent(buffer, 0, thisChunkSize); content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4"); content.Headers.TryAddWithoutValidation("Content-Range", $"bytes {start}-{end}/{fileSize}"); using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { Content = content }; using var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } } private async Task<Upload> PollStatus( HttpClient client, string token, string publishId, CancellationToken cancellationToken ) { var deadline = timeProvider.GetUtcNow().AddMinutes(1); while (timeProvider.GetUtcNow() < deadline) { using var request = new HttpRequestMessage( HttpMethod.Post, new Uri(TikTokBase, "/v2/post/publish/status/fetch/") ); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Content = JsonContent.Create(new StatusRequest(publishId)); using var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); var status = await response.Content.ReadFromJsonAsync<TikTokResponse<StatusData>>(cancellationToken) ?? throw new Exception("Failed to deserialize TikTok status response"); if (status.Data.Status == "FAILED") throw new Exception($"TikTok upload failed for publish_id {publishId}"); if (status.Data.Status == "PUBLISH_COMPLETE") { var videoId = status.Data.PublicalyAvailablePostId?.FirstOrDefault(); var now = timeProvider.GetUtcNow(); return new Upload( videoId is null ? null : new Uri($"https://www.tiktok.com/video/{videoId}"), now, now, "TikTok" ); } await Task.Delay(TimeSpan.FromSeconds(1), timeProvider, cancellationToken); } throw new Exception($"TikTok upload timed out for publish_id {publishId}"); } private sealed record InitRequest( [property: JsonPropertyName("post_info")] PostInfo PostInfo, [property: JsonPropertyName("source_info")] SourceInfo SourceInfo ); private sealed record PostInfo( [property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("privacy_level")] string PrivacyLevel, [property: JsonPropertyName("brand_content_toggle")] bool BrandContentToggle = false, [property: JsonPropertyName("brand_organic_toggle")] bool BrandOrganicToggle = false ); private sealed record SourceInfo( [property: JsonPropertyName("video_size")] long VideoSize, [property: JsonPropertyName("chunk_size")] long ChunkSize, [property: JsonPropertyName("total_chunk_count")] long TotalChunkCount, [property: JsonPropertyName("source")] string Source = "FILE_UPLOAD" ); private sealed record StatusRequest([property: JsonPropertyName("publish_id")] string PublishId); private sealed record TikTokResponse<T>( [property: JsonPropertyName("data")] T Data, [property: JsonPropertyName("error")] TikTokError Error ); private sealed record TikTokError( [property: JsonPropertyName("code")] string Code, [property: JsonPropertyName("message")] string Message ); private sealed record InitData( [property: JsonPropertyName("publish_id")] string PublishId, [property: JsonPropertyName("upload_url")] string UploadUrl ); private sealed record StatusData( [property: JsonPropertyName("status")] string Status, [property: JsonPropertyName("publicaly_available_post_id")] string[]? PublicalyAvailablePostId ); }