Commit: 212e7f1
Parent: a4c62ab

Add TikTok uploads

Mårten Åsberg committed on 2026-05-17 at 19:39
.containerfile +2 -0
diff --git a/.containerfile b/.containerfile
index beb0e02..5726381 100644
@@ -10,6 +10,7 @@ COPY ./src/Domain/Domain.csproj ./src/Domain/packages.lock.json ./src/Domain/
COPY ./src/Infrastructure/Ai/Ai.csproj ./src/Infrastructure/Ai/packages.lock.json ./src/Infrastructure/Ai/
COPY ./src/Infrastructure/Database/Database.csproj ./src/Infrastructure/Database/packages.lock.json ./src/Infrastructure/Database/
COPY ./src/Infrastructure/Ffmpeg/Ffmpeg.csproj ./src/Infrastructure/Ffmpeg/packages.lock.json ./src/Infrastructure/Ffmpeg/
COPY ./src/Infrastructure/TikTok/TikTok.csproj ./src/Infrastructure/TikTok/packages.lock.json ./src/Infrastructure/TikTok/
COPY ./src/Infrastructure/YouTube/YouTube.csproj ./src/Infrastructure/YouTube/packages.lock.json ./src/Infrastructure/YouTube/
RUN dotnet restore --locked-mode
@@ -19,6 +20,7 @@ COPY ./src/Domain/ ./src/Domain/
COPY ./src/Infrastructure/Ai/ ./src/Infrastructure/Ai/
COPY ./src/Infrastructure/Database/ ./src/Infrastructure/Database/
COPY ./src/Infrastructure/Ffmpeg/ ./src/Infrastructure/Ffmpeg/
COPY ./src/Infrastructure/TikTok/ ./src/Infrastructure/TikTok/
COPY ./src/Infrastructure/YouTube/ ./src/Infrastructure/YouTube/
RUN dotnet build ./src/Api/Api.csproj --no-restore --configuration Release
Slopper.slnx +1 -0
diff --git a/Slopper.slnx b/Slopper.slnx
index eac7134..47feb6b 100644
@@ -8,6 +8,7 @@
<Project Path="src/Infrastructure/Ai/Ai.csproj" />
<Project Path="src/Infrastructure/Database/Database.csproj" />
<Project Path="src/Infrastructure/Ffmpeg/Ffmpeg.csproj" />
<Project Path="src/Infrastructure/TikTok/TikTok.csproj" />
<Project Path="src/Infrastructure/YouTube/YouTube.csproj" />
</Folder>
</Solution>
src/Api/Api.csproj +1 -0
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
index 71080d5..9adcbc9 100644
@@ -9,6 +9,7 @@
<ProjectReference Include="..\Infrastructure\Ai\Ai.csproj" />
<ProjectReference Include="..\Infrastructure\Database\Database.csproj" />
<ProjectReference Include="..\Infrastructure\Ffmpeg\Ffmpeg.csproj" />
<ProjectReference Include="..\Infrastructure\TikTok\TikTok.csproj" />
<ProjectReference Include="..\Infrastructure\YouTube\YouTube.csproj" />
</ItemGroup>
<ItemGroup>
src/Api/ApiEndpoints.cs +8 -2
diff --git a/src/Api/ApiEndpoints.cs b/src/Api/ApiEndpoints.cs
index 8564f37..aecdbcd 100644
@@ -135,7 +135,13 @@ public sealed record Clip(
details.Clip.CreatedAt,
details.Clip.Caption,
[.. details.Clip.Tags.Select(t => t.Value)],
[.. details.Clip.Uploads.Select(u => new ClipUpload(u.CanonicalUrl.ToString(), u.Platform, u.PublishedAt))],
[
.. details.Clip.Uploads.Select(u => new ClipUpload(
u.CanonicalUrl?.ToString(),
u.Platform,
u.PublishedAt
)),
],
new ClipMediaInfo(
details.MediaInfo?.Name,
details.MediaInfo?.SeriesName,
@@ -144,6 +150,6 @@ public sealed record Clip(
);
}
public sealed record ClipUpload(string CanonicalUrl, string Platform, DateTimeOffset PublishedAt);
public sealed record ClipUpload(string? CanonicalUrl, string Platform, DateTimeOffset PublishedAt);
public sealed record ClipMediaInfo(string? Name, string? SeriesName, DateOnly? OriginalReleaseDate);
src/Api/Program.cs +8 -1
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index 9cc38ee..e22dd17 100644
@@ -12,6 +12,7 @@ using Slopper.Domain;
using Slopper.Infrastructure.Ai;
using Slopper.Infrastructure.Database;
using Slopper.Infrastructure.Ffmpeg;
using Slopper.Infrastructure.TikTok;
using Slopper.Infrastructure.YouTube;
using Winton.Extensions.Configuration.Consul;
@@ -36,7 +37,13 @@ builder.Services.AddOpenApi();
builder.Services.AddClipSelector().AddClipGenerator().AddCleaner().AddUploader().AddClipDetailsService();
builder.Services.AddJellyfinDatabase().AddSlopperDatabase().AddFfmpegServices().AddAi().AddYouTubeUploader();
builder
.Services.AddJellyfinDatabase()
.AddSlopperDatabase()
.AddFfmpegServices()
.AddAi()
.AddYouTubeUploader()
.AddTikTokUploader();
builder
.Services.AddClipGenerationJobOptions()
src/Api/TikTokApiEndpoints.cs +51 -3
diff --git a/src/Api/TikTokApiEndpoints.cs b/src/Api/TikTokApiEndpoints.cs
index 51f1183..8fc2316 100644
@@ -1,7 +1,14 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Quartz;
using Slopper.Api.TikTokAuth;
namespace Slopper.Api;
@@ -15,7 +22,9 @@ public static class TikTokApiEndpoints
var tiktok = endpoints.MapGroup("/tiktok").RequireTikTokAuthorization();
tiktok.MapGet("/login", Login).WithDisplayName("TikTok Login");
tiktok.MapGet("/name", Name).WithDisplayName("Returns signed in TikTok user's name");
tiktok.MapGet("/upload", GetUploadJobStatus).WithDisplayName("Get TikTok upload job status.");
tiktok.MapPut("/upload", StartUploadJob).WithDisplayName("Triggers a job uploading clips to TikTok.");
return tiktok;
}
@@ -23,6 +32,45 @@ public static class TikTokApiEndpoints
private static RedirectHttpResult Login() => TypedResults.Redirect("/admin/tiktok");
private static Results<Ok<string>, UnauthorizedHttpResult> Name(HttpContext httpContext) =>
httpContext.User.Identity?.Name is string name ? TypedResults.Ok(name) : TypedResults.Unauthorized();
private static async Task<Ok<JobStatus>> GetUploadJobStatus(
[FromServices] ISchedulerFactory schedulerFactory,
CancellationToken cancellationToken
)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
var isRunning = await IsUploadJobRunning(scheduler, cancellationToken);
return TypedResults.Ok(new JobStatus(isRunning));
}
private static async Task<Accepted> StartUploadJob(
[FromServices] TikTokAccessTokenProvider accessTokenProvider,
HttpContext httpContext,
[FromServices] ISchedulerFactory schedulerFactory,
CancellationToken cancellationToken
)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
if (await IsUploadJobRunning(scheduler, cancellationToken))
{
return TypedResults.Accepted("/api/tiktok/upload");
}
var result = await httpContext.AuthenticateAsync();
var token =
result.Properties?.GetTokenValue("access_token")
?? throw new Exception("TikTok access token is not available");
accessTokenProvider.AccessToken = token;
await scheduler.TriggerJob(UploadJob.Key, new JobDataMap { ["platform"] = "TikTok" }, cancellationToken);
return TypedResults.Accepted("/api/tiktok/upload");
}
private static async Task<bool> IsUploadJobRunning(IScheduler scheduler, CancellationToken cancellationToken)
{
var currentlyExecutingJobs = await scheduler.GetCurrentlyExecutingJobs(cancellationToken);
return currentlyExecutingJobs.Any(j =>
j.JobDetail.Key == UploadJob.Key && j.MergedJobDataMap.GetString("platform") is "TikTok"
);
}
}
src/Api/TikTokAuth/TikTokAccessTokenProvider.cs +12 -0
diff --git a/src/Api/TikTokAuth/TikTokAccessTokenProvider.cs b/src/Api/TikTokAuth/TikTokAccessTokenProvider.cs
new file mode 100644
index 0000000..3155aa6
@@ -0,0 +1,12 @@
using System;
using Slopper.Infrastructure.TikTok;
namespace Slopper.Api.TikTokAuth;
internal sealed class TikTokAccessTokenProvider : ITikTokAccessTokenProvider
{
public string? AccessToken { get; set; }
string ITikTokAccessTokenProvider.AccessToken =>
AccessToken ?? throw new Exception("TikTok access token is not available");
}
src/Api/TikTokAuth/TikTokAuthExtensions.cs +6 -0
diff --git a/src/Api/TikTokAuth/TikTokAuthExtensions.cs b/src/Api/TikTokAuth/TikTokAuthExtensions.cs
index 13e6674..33c3525 100644
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Slopper.Infrastructure.TikTok;
namespace Slopper.Api.TikTokAuth;
@@ -98,6 +99,11 @@ public static class TikTokAuthExtensions
auth.Services.AddOptions<OAuthOptions>(SchemeName).BindConfiguration("TikTok");
auth.Services.AddSingleton<TikTokAccessTokenProvider>();
auth.Services.AddSingleton<ITikTokAccessTokenProvider>(sp =>
sp.GetRequiredService<TikTokAccessTokenProvider>()
);
return auth;
}
}
src/Api/Upload.cs +0 -9
diff --git a/src/Api/Upload.cs b/src/Api/Upload.cs
deleted file mode 100644
index 678f129..0000000
@@ -1,9 +0,0 @@
using System;
namespace Slopper.Api;
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, DateTimeOffset PublishedAt, string Platform)
{
public static Upload FromDomain(Domain.Upload upload) =>
new(upload.CanonicalUrl, upload.CreatedAt, upload.PublishedAt, upload.Platform);
}
src/Api/packages.lock.json +8 -2
diff --git a/src/Api/packages.lock.json b/src/Api/packages.lock.json
index 48abddc..188064d 100644
@@ -604,12 +604,18 @@
"SubtitlesParserV2": "[2.4.0, )"
}
},
"slopper.infrastructure.tiktok": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Http": "[11.0.0-preview.3.26207.106, )",
"Microsoft.Extensions.Options": "[11.0.0-preview.3.26207.106, )",
"Slopper.Domain": "[1.0.0, )"
}
},
"Slopper.Infrastructure.YouTube": {
"type": "Project",
"dependencies": {
"Google.Apis.YouTube.v3": "[1.74.0.4137, )",
"Microsoft.Extensions.Options": "[11.0.0-preview.3.26207.106, )",
"Microsoft.Extensions.Options.ConfigurationExtensions": "[11.0.0-preview.3.26207.106, )",
"Slopper.Domain": "[1.0.0, )"
}
},
src/Domain/Upload.cs +1 -1
diff --git a/src/Domain/Upload.cs b/src/Domain/Upload.cs
index dc7228a..43b52dc 100644
@@ -2,4 +2,4 @@ using System;
namespace Slopper.Domain;
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, DateTimeOffset PublishedAt, string Platform);
public sealed record Upload(Uri? CanonicalUrl, DateTimeOffset CreatedAt, DateTimeOffset PublishedAt, string Platform);
src/Frontend/src/Clip.ts +1 -1
diff --git a/src/Frontend/src/Clip.ts b/src/Frontend/src/Clip.ts
index f48af63..96eff0c 100644
@@ -1,5 +1,5 @@
export interface Upload {
canonicalUrl: string;
canonicalUrl: string | null;
platform: string;
publishedAt: Date;
}
src/Frontend/src/components/ClipDetails.vue +5 -1
diff --git a/src/Frontend/src/components/ClipDetails.vue b/src/Frontend/src/components/ClipDetails.vue
index 0e01fcb..6d75278 100644
@@ -50,7 +50,11 @@ function formatReleaseDate(date: string): string {
<dd>{{ formatDate(clip.createdAt) }}</dd>
<template v-for="upload in clip.uploads" :key="upload.canonicalUrl">
<dt>{{ upload.platform }}</dt>
<dd><a :href="upload.canonicalUrl" rel="noopener noreferrer">{{ formatDate(upload.publishedAt) }}</a></dd>
<dd>
<a v-if="upload.canonicalUrl" :href="upload.canonicalUrl"
rel="noopener noreferrer">{{ formatDate(upload.publishedAt) }}</a>
<template v-else>{{ formatDate(upload.publishedAt) }}</template>
</dd>
</template>
</dl>
</aside>
src/Frontend/src/pages/AdminTikTokPage.vue +53 -10
diff --git a/src/Frontend/src/pages/AdminTikTokPage.vue b/src/Frontend/src/pages/AdminTikTokPage.vue
index 6af4fb9..2b4addc 100644
@@ -1,16 +1,58 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getTikTokUsername, } from "../services/api";
import { ref, onMounted, onUnmounted } from "vue";
import { getTikTokUploadStatus, startTikTokUpload, type JobStatus } from "../services/api";
import StatusCard from "../components/StatusCard.vue";
const unauthenticated = ref(true);
const username = ref<string | null>(null);
const status = ref<JobStatus | null>(null);
const unauthenticated = ref(false);
const starting = ref(false);
let pollInterval: ReturnType<typeof setInterval> | null = null;
function startPolling() {
stopPolling();
pollInterval = setInterval(async () => {
const result = await getTikTokUploadStatus();
if (pollInterval === null) return;
if (result === null) {
unauthenticated.value = true;
stopPolling();
return;
}
status.value = result;
if (!result.isRunning) stopPolling();
}, 1000);
}
function stopPolling() {
if (pollInterval !== null) {
clearInterval(pollInterval);
pollInterval = null;
}
}
onMounted(async () => {
const result = await getTikTokUsername();
if (result == null) return;
unauthenticated.value = false;
username.value = result;
const result = await getTikTokUploadStatus();
if (result === null) {
unauthenticated.value = true;
} else {
status.value = result;
if (result.isRunning) startPolling();
}
});
onUnmounted(stopPolling);
async function startUpload() {
starting.value = true;
try {
await startTikTokUpload();
status.value = { isRunning: true, nextScheduledRun: null };
startPolling();
} finally {
starting.value = false;
}
}
</script>
<template>
@@ -18,9 +60,10 @@ onMounted(async () => {
<a href="/api/tiktok/login" class="button">Login to TikTok</a>
</div>
<template v-else-if="username">
<template v-else-if="status">
<h1>TikTok</h1>
<p>Welcome {{ username }}!</p>
<StatusCard :is-running="status.isRunning" running-text="Upload in progress" idle-text="Idle" />
<button :disabled="status.isRunning || starting" @click="startUpload">Start upload</button>
</template>
</template>
src/Frontend/src/services/api.ts +8 -3
diff --git a/src/Frontend/src/services/api.ts b/src/Frontend/src/services/api.ts
index b2d4b13..fa2898c 100644
@@ -17,11 +17,16 @@ export async function startYouTubeUpload(): Promise<void> {
if (!res.ok) throw new Error(`Unexpected response: ${res.status}`);
}
export async function getTikTokUsername(): Promise<string | null> {
const res = await fetch("/api/tiktok/name");
export async function getTikTokUploadStatus(): Promise<JobStatus | null> {
const res = await fetch("/api/tiktok/upload");
if (res.status === 403) return null;
if (!res.ok) throw new Error(`Unexpected response: ${res.status}`);
return res.text();
return res.json() as Promise<JobStatus>;
}
export async function startTikTokUpload(): Promise<void> {
const res = await fetch("/api/tiktok/upload", { method: "PUT" });
if (!res.ok) throw new Error(`Unexpected response: ${res.status}`);
}
export async function fetchClips(after?: string): Promise<Array<Clip>> {
src/Infrastructure/Database/Slopper/Migrations/20260517171132_NullableCanonicalUrl.Designer.cs +113 -0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260517171132_NullableCanonicalUrl.Designer.cs b/src/Infrastructure/Database/Slopper/Migrations/20260517171132_NullableCanonicalUrl.Designer.cs
new file mode 100644
index 0000000..6b47aa7
@@ -0,0 +1,113 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Slopper.Infrastructure.Database.Slopper;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
[DbContext(typeof(SlopperDbContext))]
[Migration("20260517171132_NullableCanonicalUrl")]
partial class NullableCanonicalUrl
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Slopper.Domain.Clip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Caption")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b.Property<Guid>("MediaItemId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("TEXT");
b.Property<TimeSpan>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Clips");
});
modelBuilder.Entity("Slopper.Domain.Clip", b =>
{
b.OwnsMany("Slopper.Domain.Tag", "Tags", b1 =>
{
b1.Property<Guid>("ClipId")
.HasColumnType("TEXT");
b1.Property<string>("Value")
.HasColumnType("TEXT");
b1.HasKey("ClipId", "Value");
b1.ToTable("Tag");
b1.WithOwner()
.HasForeignKey("ClipId");
});
b.OwnsMany("Slopper.Domain.Upload", "Uploads", b1 =>
{
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<string>("CanonicalUrl")
.HasColumnType("TEXT");
b1.Property<Guid>("ClipId")
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b1.Property<string>("Platform")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("PublishedAt")
.HasColumnType("TEXT");
b1.HasKey("Id");
b1.HasIndex("ClipId");
b1.ToTable("Upload");
b1.WithOwner()
.HasForeignKey("ClipId");
});
b.Navigation("Tags");
b.Navigation("Uploads");
});
#pragma warning restore 612, 618
}
}
}
src/Infrastructure/Database/Slopper/Migrations/20260517171132_NullableCanonicalUrl.cs +38 -0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260517171132_NullableCanonicalUrl.cs b/src/Infrastructure/Database/Slopper/Migrations/20260517171132_NullableCanonicalUrl.cs
new file mode 100644
index 0000000..0a6589f
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
/// <inheritdoc />
public partial class NullableCanonicalUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "CanonicalUrl",
table: "Upload",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "CanonicalUrl",
table: "Upload",
type: "TEXT",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true
);
}
}
}
src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs +0 -1
diff --git a/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs b/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs
index f61db87..771a203 100644
@@ -75,7 +75,6 @@ namespace Slopper.Infrastructure.Database.Slopper.Migrations
.HasColumnType("INTEGER");
b1.Property<string>("CanonicalUrl")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<Guid>("ClipId")
src/Infrastructure/Database/Slopper/SlopperDbContext.cs +2 -1
diff --git a/src/Infrastructure/Database/Slopper/SlopperDbContext.cs b/src/Infrastructure/Database/Slopper/SlopperDbContext.cs
index 7507cfa..afdf0bb 100644
@@ -24,7 +24,8 @@ internal sealed class SlopperDbContext(DbContextOptions options) : DbContext(opt
{
b.HasKey("Id");
b.Property("Id").ValueGeneratedOnAdd();
b.Property(u => u.CanonicalUrl).HasConversion(u => u.ToString(), s => new Uri(s));
b.Property(u => u.CanonicalUrl)
.HasConversion(u => u == null ? null : u.ToString(), s => s == null ? null : new Uri(s));
}
);
}
src/Infrastructure/TikTok/ITikTokAccessTokenProvider.cs +6 -0
diff --git a/src/Infrastructure/TikTok/ITikTokAccessTokenProvider.cs b/src/Infrastructure/TikTok/ITikTokAccessTokenProvider.cs
new file mode 100644
index 0000000..449a931
@@ -0,0 +1,6 @@
namespace Slopper.Infrastructure.TikTok;
public interface ITikTokAccessTokenProvider
{
string AccessToken { get; }
}
src/Infrastructure/TikTok/ServiceCollectionExtensions.cs +21 -0
diff --git a/src/Infrastructure/TikTok/ServiceCollectionExtensions.cs b/src/Infrastructure/TikTok/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..e5d4bb1
@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using Slopper.Domain;
namespace Slopper.Infrastructure.TikTok;
public static class ServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddTikTokUploader()
{
services.AddOptions<TikTokUploaderOptions>().BindConfiguration("TikTok").ValidateOnStart();
services.AddHttpClient("TikTok");
services.AddKeyedTransient<IUploader, TikTokUploader>("TikTok");
return services;
}
}
}
src/Infrastructure/TikTok/TikTok.csproj +13 -0
diff --git a/src/Infrastructure/TikTok/TikTok.csproj b/src/Infrastructure/TikTok/TikTok.csproj
new file mode 100644
index 0000000..cf481c1
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Slopper.Infrastructure.TikTok</RootNamespace>
<AssemblyName>Slopper.Infrastructure.TikTok</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>
src/Infrastructure/TikTok/TikTokUploader.cs +195 -0
diff --git a/src/Infrastructure/TikTok/TikTokUploader.cs b/src/Infrastructure/TikTok/TikTokUploader.cs
new file mode 100644
index 0000000..6a7f56f
@@ -0,0 +1,195 @@
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)
{
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
);
}
src/Infrastructure/TikTok/TikTokUploaderOptions.cs +8 -0
diff --git a/src/Infrastructure/TikTok/TikTokUploaderOptions.cs b/src/Infrastructure/TikTok/TikTokUploaderOptions.cs
new file mode 100644
index 0000000..69f8803
@@ -0,0 +1,8 @@
namespace Slopper.Infrastructure.TikTok;
public sealed class TikTokUploaderOptions
{
public required string PrivacyLevel { get; set; } = "SELF_ONLY";
public long ChunkSize { get; set; } = 5_242_880; // 5 MB (min)
}
src/Infrastructure/TikTok/packages.lock.json +153 -0
diff --git a/src/Infrastructure/TikTok/packages.lock.json b/src/Infrastructure/TikTok/packages.lock.json
new file mode 100644
index 0000000..0e918ce
@@ -0,0 +1,153 @@
{
"version": 2,
"dependencies": {
"net11.0": {
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.2.6, )",
"resolved": "1.2.6",
"contentHash": "KMSJG+jfk7vjP52QkWB99qWespXCPAzG/IaMCMRHYWumJEAGKQYm2HtyWG6eqnOwDitH96i1cqq5EVesyOtPmg=="
},
"Microsoft.Extensions.Http": {
"type": "Direct",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "CsZ7uQ32bwS0MlElEsz5OrBKn3CGd5raaG+oY4BMfs7e7c+2sjFXmBMgPp5ttCXAciD6spVP++Ol2OzViNsuVw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Diagnostics": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Logging": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Logging.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Options": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Options": {
"type": "Direct",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "2kd+Lqnh8bvBun9wH+MUZ15Pb+4LAY0ErmeBhy5bsliLQyjRsoejWEOgyjkiZpLj9iLNM8tYAt6SW2vkzFbR8g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Primitives": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "Do6yieeVHdvwyIKED9oPfFHAH5PAkvwDjR+65u2ZS/ddSHvEtOd5e5rrAQyhIIflbCz13graO/XkBQQV5EJNkg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Primitives": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "DAFozg1P/fA2yh36sYLS/NMDxGCFATUFNYbgQi1wbkFT2cFYqEsK/VYbTXsiXKQfr3G/d4Rnorpe0In2WBaMIA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "ulM+V32IqcFYIqIxxT7MZjSfpQ9T3k33chyBrnjcfSm1BQFIgtdTcXAlZJpzZmFAklh4PHG7BFuVw9PIJ1KcUg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Configuration.Abstractions": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "aq5Lc0SLQiJGRauG829dTpoMygFLpuelBspnnNi4rRKa8C8eqruxdrCIzJ0po2NQlpgoNprHlC0vQsT0fDxH4w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "+gJnv1/kfXLXPv21R3iluhKqfXdf2zPWUaHBiSvlJurThv2D5HRUfU5z5SpmBII4I0JSpuprX9DlHrKz/1wCXA=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "Gv4wwBodQj50cbyfXvoHRue1sEA4hVSwBv2bR0Oi8Re/cxvxyfrBKWJg5KYANDQW242uohhzDSOmyx0kY8wNLw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Diagnostics.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Options.ConfigurationExtensions": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "Jw8scnPDYKkBJE3LSvAQQ/P4OBypQclFuFqcYo3RLGt5zr9EhC1V0ozwxr8/xe/66IHfPA9YhdhYegAn4Y7t5Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Options": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "nyfgC4LADfHGoen9Hmuc1iwj047w9Vm+f+ARGJL8spYqdOBDQIhnsSA2FpkY3w3yoZu2hzOmluB7ML0NigxHbw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Logging.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Options": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "IBOlwyX13ax6/fXA7AoZFswKFytta9TExBv3/8qemMJGBoDXYlQEcw4WerHQCvmerJ5uP2o8bjIAvxcNdTZVLQ=="
},
"Slopper.Domain": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.AI.Abstractions": "[10.5.2, )",
"Microsoft.Extensions.Logging.Abstractions": "[11.0.0-preview.3.26207.106, )",
"Microsoft.Extensions.Options": "[11.0.0-preview.3.26207.106, )",
"Microsoft.Extensions.Options.ConfigurationExtensions": "[11.0.0-preview.3.26207.106, )"
}
},
"Microsoft.Extensions.AI.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.5.2, )",
"resolved": "10.5.2",
"contentHash": "Ei+YWV9Ybnps7pR1dgjlG29gelXEwZkhLVAcWmKe6HvXS6LNBYgSdWiY3Hk9OZXYtK34rv/NtLWBQYQGOBQYPQ=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "0LktkD4eySHjlglnee7jt/I3KPea+MPIxLTYBacH1P/iluOCl7VVKwpG/bciZMkyaNnfslY2E70t6nfvjq51vA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "CentralTransitive",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "DbZcRfBrCSLas0cS0iKdiez9kM/7Z3rz5xlDJKqAxhGPGzhKJu82Z3+LNANPZSTUbyYnNawb3Euvv8ACPPatjQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Configuration.Binder": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Options": "11.0.0-preview.3.26207.106",
"Microsoft.Extensions.Primitives": "11.0.0-preview.3.26207.106"
}
}
},
"net11.0/linux-arm64": {},
"net11.0/linux-x64": {},
"net11.0/osx-arm64": {},
"net11.0/osx-x64": {},
"net11.0/win-arm64": {},
"net11.0/win-x64": {}
}
}
\ No newline at end of file