Files
youtube-studio-flow/backend/src/modules/youtube-sync/youtube-sync.service.ts
T

148 lines
5.3 KiB
TypeScript

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;
}
}