Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { QuotaService } from '../../shared/quota/quota.service';
|
||||
import { YouTubeApiClient } from './youtube-api.client';
|
||||
import { hashMetadata } from '../../shared/render-engine/hash';
|
||||
import { PrivacyStatus, Prisma, Video } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class YouTubeSyncService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly ytClient: YouTubeApiClient,
|
||||
) {}
|
||||
|
||||
async fetchRemote(youtubeVideoId: string, channelId: string) {
|
||||
if (!(await this.quota.canSpend(1))) throw new Error('Quota exhausted');
|
||||
return this.ytClient.getVideoMetadata(youtubeVideoId, channelId);
|
||||
}
|
||||
|
||||
async pushUpdate(
|
||||
video: { youtubeVideoId: string; channelId: string },
|
||||
meta: {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
categoryId?: string;
|
||||
privacyStatus: PrivacyStatus;
|
||||
scheduledAt?: Date | null;
|
||||
defaultLanguage?: string;
|
||||
defaultAudioLanguage?: string;
|
||||
selfDeclaredMadeForKids?: boolean;
|
||||
embeddable?: boolean;
|
||||
license?: string;
|
||||
recordingDate?: Date | null;
|
||||
},
|
||||
ctx?: { actionId?: string; actionType?: string },
|
||||
) {
|
||||
await this.ytClient.updateVideoMetadata(video.youtubeVideoId, meta, video.channelId, ctx);
|
||||
}
|
||||
|
||||
async detectConflictsForVideos(
|
||||
videoIds: string[],
|
||||
): Promise<{ scanned: number; conflicts: number; quotaExhausted: boolean }> {
|
||||
if (videoIds.length === 0) return { scanned: 0, conflicts: 0, quotaExhausted: false };
|
||||
|
||||
const videos = await this.prisma.video.findMany({ where: { id: { in: videoIds } } });
|
||||
|
||||
// Batch API calls are scoped to a channel's OAuth client, so group first.
|
||||
const byChannel = new Map<string, Video[]>();
|
||||
for (const v of videos) {
|
||||
const bucket = byChannel.get(v.channelId);
|
||||
if (bucket) bucket.push(v);
|
||||
else byChannel.set(v.channelId, [v]);
|
||||
}
|
||||
|
||||
let scanned = 0;
|
||||
let conflicts = 0;
|
||||
|
||||
for (const [channelId, channelVideos] of byChannel) {
|
||||
for (let i = 0; i < channelVideos.length; i += 50) {
|
||||
const batch = channelVideos.slice(i, i + 50);
|
||||
if (!(await this.quota.canSpend(1))) {
|
||||
return { scanned, conflicts, quotaExhausted: true };
|
||||
}
|
||||
|
||||
const items = await this.ytClient.getVideosBatch(
|
||||
batch.map((v) => v.youtubeVideoId),
|
||||
channelId,
|
||||
{ actionId: randomUUID(), actionType: 'conflict_detection' },
|
||||
);
|
||||
const remoteByYtId = new Map(items.map((item) => [item.id, item]));
|
||||
|
||||
for (const video of batch) {
|
||||
const remote = remoteByYtId.get(video.youtubeVideoId);
|
||||
if (!remote) continue; // YouTube didn't return this ID; likely deleted, purge job handles it
|
||||
const conflict = await this.applyConflictDetection(video, remote);
|
||||
scanned++;
|
||||
if (conflict) conflicts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { scanned, conflicts, quotaExhausted: false };
|
||||
}
|
||||
|
||||
private async applyConflictDetection(video: Video, remote: any): Promise<boolean> {
|
||||
const remoteDescription = remote.snippet?.description ?? '';
|
||||
const remotePrivacyStatus = remote.status?.privacyStatus?.toUpperCase() ?? null;
|
||||
const remoteRecordingDate = remote.recordingDetails?.recordingDate
|
||||
? new Date(remote.recordingDetails.recordingDate)
|
||||
: null;
|
||||
|
||||
const remoteHash = hashMetadata({
|
||||
title: remote.snippet?.title ?? '',
|
||||
description: remoteDescription,
|
||||
tags: remote.snippet?.tags ?? [],
|
||||
categoryId: remote.snippet?.categoryId,
|
||||
privacyStatus: remotePrivacyStatus,
|
||||
defaultLanguage: remote.snippet?.defaultLanguage,
|
||||
defaultAudioLanguage: remote.snippet?.defaultAudioLanguage,
|
||||
selfDeclaredMadeForKids: remote.status?.selfDeclaredMadeForKids,
|
||||
embeddable: remote.status?.embeddable,
|
||||
license: remote.status?.license,
|
||||
recordingDate: remoteRecordingDate,
|
||||
});
|
||||
|
||||
const conflict = remoteHash !== video.lastSyncedHash;
|
||||
|
||||
if (conflict) {
|
||||
const pendingRemoteSnapshot = {
|
||||
title: remote.snippet?.title ?? '',
|
||||
tags: remote.snippet?.tags ?? [],
|
||||
categoryId: remote.snippet?.categoryId ?? null,
|
||||
privacyStatus: remotePrivacyStatus,
|
||||
defaultLanguage: remote.snippet?.defaultLanguage ?? null,
|
||||
defaultAudioLanguage: remote.snippet?.defaultAudioLanguage ?? null,
|
||||
selfDeclaredMadeForKids: remote.status?.selfDeclaredMadeForKids ?? false,
|
||||
embeddable: remote.status?.embeddable ?? true,
|
||||
license: remote.status?.license ?? 'youtube',
|
||||
recordingDate: remoteRecordingDate ? remoteRecordingDate.toISOString().slice(0, 10) : null,
|
||||
};
|
||||
|
||||
await this.prisma.video.update({
|
||||
where: { id: video.id },
|
||||
data: {
|
||||
remoteConflict: true,
|
||||
pendingRemoteSnapshot,
|
||||
pendingRemoteDescription: remoteDescription,
|
||||
},
|
||||
});
|
||||
} else if (video.remoteConflict || video.pendingRemoteSnapshot) {
|
||||
// Self-heal: remote matches lastSyncedHash again (e.g. the creator reverted their edit).
|
||||
await this.prisma.video.update({
|
||||
where: { id: video.id },
|
||||
data: {
|
||||
remoteConflict: false,
|
||||
pendingRemoteSnapshot: Prisma.JsonNull,
|
||||
pendingRemoteDescription: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return conflict;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user