148 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|