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(); 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 { 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; } }