import { IDriveService } from "../interfaces/IDriveService" import { IShopifyMediaService } from "../interfaces/IShopifyMediaService" import { INetworkService } from "../interfaces/INetworkService" import { Config } from "../config" export class MediaService { private driveService: IDriveService private shopifyMediaService: IShopifyMediaService private networkService: INetworkService private config: Config constructor( driveService: IDriveService, shopifyMediaService: IShopifyMediaService, networkService: INetworkService, config: Config ) { this.driveService = driveService this.shopifyMediaService = shopifyMediaService this.networkService = networkService this.config = config } getDiagnostics(sku: string, shopifyProductId: string) { const results = { drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null }, shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null }, matching: { status: 'pending', error: null } } // 1. Unsafe Drive Check try { const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId) results.drive.folderId = folder.getId() results.drive.folderUrl = folder.getUrl() const files = this.driveService.getFiles(folder.getId()) results.drive.fileCount = files.length results.drive.status = 'ok' } catch (e) { results.drive.status = 'error' results.drive.error = e.toString() } // 2. Unsafe Shopify Check try { if (shopifyProductId) { const media = this.shopifyMediaService.getProductMedia(shopifyProductId) results.shopify.mediaCount = media.length // Admin URL construction (Best effort) // Assuming standard Shopify admin pattern const domain = this.shopifyMediaService.getShopDomain? this.shopifyMediaService.getShopDomain() : 'admin.shopify.com'; results.shopify.adminUrl = `https://${domain.replace('.myshopify.com','')}.myshopify.com/admin/products/${shopifyProductId.split('/').pop()}` results.shopify.status = 'ok' } else { results.shopify.status = 'skipped' // Not linked yet } } catch (e) { results.shopify.status = 'error' results.shopify.error = e.toString() } return results } getUnifiedMediaState(sku: string, shopifyProductId: string): any[] { console.log(`MediaService: Getting unified state for SKU ${sku}`) // 1. Get Drive Files const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId) // We need strict file list. // Optimization: getFiles() usually returns limited info. // We might need to iterate and pull props if getFiles() doesn't include appProperties (DriveApp doesn't). const driveFiles = this.driveService.getFiles(folder.getId()) // 2. Get Shopify Media let shopifyMedia: any[] = [] if (shopifyProductId) { shopifyMedia = this.shopifyMediaService.getProductMedia(shopifyProductId) } // 3. Match const unifiedState: any[] = [] const matchedShopifyIds = new Set() // PRE-PASS: Identify Sidecar Thumbnails // Map const sidecarThumbMap = new Map(); const sidecarFileIds = new Set(); // Map of Drive Files (Enriched) const driveFileStats = driveFiles.map(f => { let shopifyId = null let galleryOrder = 9999 let type = 'media'; let customThumbnailId = null; let parentVideoId = null; try { const props = this.driveService.getFileProperties(f.getId()) if (props['shopify_media_id']) shopifyId = props['shopify_media_id'] if (props['gallery_order']) galleryOrder = parseInt(props['gallery_order']) if (props['type']) type = props['type']; if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id']; if (props['parent_video_id']) parentVideoId = props['parent_video_id']; console.log(`[DEBUG] File ${f.getName()} Props:`, JSON.stringify(props)); } catch (e) { console.warn(`Failed to get properties for ${f.getName()}`) } return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId } }) // Populate Sidecar Map driveFileStats.forEach(stat => { if (stat.type === 'thumbnail' && stat.parentVideoId) { sidecarFileIds.add(stat.file.getId()); // URL-based approach failed (CORS/Auth). // Switch to Server-Side Base64 encoding (Robust). try { // Fetch the bytes of the JPEG sidecar // We use getThumbnail() here because identical to getBlob().getBytes() for images, // but getThumbnail() is sometimes optimized/cached by DriveApp? // actually getBlob() is safer for the "original" sidecar content. const bytes = stat.file.getBlob().getBytes(); const b64 = Utilities.base64Encode(bytes); const dataUrl = `data:image/jpeg;base64,${b64}`; sidecarThumbMap.set(stat.parentVideoId, dataUrl); } catch (e) { console.warn(`[MediaService] Failed to read sidecar file ${stat.file.getName()}: ${e}`); } } }); // Sort: Gallery Order ASC, then Filename ASC driveFileStats.sort((a, b) => { if (a.galleryOrder !== b.galleryOrder) { return a.galleryOrder - b.galleryOrder } return a.file.getName().localeCompare(b.file.getName()) }) // Match Logic (Strict ID Match Only) driveFileStats.forEach(d => { // Skip Sidecar Files in main list if (sidecarFileIds.has(d.file.getId())) return; let match = null let isProcessing = false let thumbnail = ""; // 1. ID Match if (d.shopifyId) { match = shopifyMedia.find(m => m.id === d.shopifyId) if (match) matchedShopifyIds.add(match.id) } // Thumbnail Logic if (match && match.preview && match.preview.image && match.preview.image.originalSrc) { thumbnail = match.preview.image.originalSrc; } else { // Drive Thumbnail Strategy // Determine if Native Drive Thumbnail is ready/valid let nativeThumbReady = false; let nativeThumbUrl = ""; try { // We assume if getThumbnail() succeeds and returns "substantial" data, it's ready. // Or check availability of thumbnailLink if we had used Advanced API. // Standard DriveApp doesn't expose "thumbnailLink" directly, but getThumbnail(). // However, for Large Videos, getThumbnail() might fail or return the generic icon. // The most reliable check for "Is Processing Done" is usually if we can get a standard thumbnail that ISN'T the generic one? // Hard to tell generic from bytes. // Alternative: If we have a Sidecar, WE ARE IN CHARGE. // We only switch if we are SURE. // Let's us try to fetch the thumbnail bytes. const thumbBlob = d.file.getThumbnail(); if (thumbBlob && thumbBlob.getContentType() !== 'application/vnd.google-apps.folder') { // Check size? Generic icons are small? // Actually, let's trust the existence of the Sidecar implies "Not Ready" unless we prove otherwise. // But we want to CLEANUP. // Let's use Advanced Drive API to check `thumbnailLink` existence for this specific file, if it has a sidecar. // This minimizes API calls to ONLY when we have a sidecar candidate. if (sidecarThumbMap.has(d.file.getId())) { const fileId = d.file.getId(); // @ts-ignore const drive = Drive; const meta = drive.Files.get(fileId, { fields: 'thumbnailLink, hasThumbnail, videoMediaMetadata' }); // Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid.. // Note: Drive sets hasThumbnail=true even for generic icons sometimes? // But `thumbnailLink` definitely exists. // For videos, `videoMediaMetadata` might NOT have 'width' while processing? // Let's check `videoMediaMetadata.width`. if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) { // SUCCESS: Drive has finished processing (we have dimensions). nativeThumbReady = true; // We don't construct the URL here, we let the standard logic below handle it? // No, we need the bytes for the frontend or a link. // `thumbnailLink` is short lived. // Let's use the native generation below. console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`); // Cleanup Sidecar Loop // TRASH the sidecar file. // We need the sidecar ID. We have to map IDs or iterate. // Optimization: We didn't store Sidecar ID in the simpler Map. // Let's find it. const sidecarId = Array.from(sidecarFileIds).find(id => { // This is slow: O(N) lookup. // But we only do this ONCE per file lifecycle. // Actually better to store ID in map? // Let's just find the file in `driveFiles` that corresponds. // We have `d.customThumbnailId`! return id === d.customThumbnailId; }); if (sidecarId) { try { this.driveService.trashFile(sidecarId); sidecarFileIds.delete(sidecarId); // Remove from set so we don't trip later sidecarThumbMap.delete(d.file.getId()); console.log(`[MediaService] Trashed sidecar ${sidecarId}`); } catch (trashErr) { console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr); } } } } } } catch (e) { // Ignore } // 1. Check Sidecar (If it still exists after potential cleanup) if (sidecarThumbMap.has(d.file.getId())) { console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`); thumbnail = sidecarThumbMap.get(d.file.getId()) || ""; isProcessing = true; // SHOW HOURGLASS (Request #3) } else if (match && ( match.status === 'PROCESSING' || match.status === 'UPLOADED' || (match.mediaContentType === 'VIDEO' && (!match.sources || match.sources.length === 0) && match.status !== 'FAILED') )) { // Shopify Processing (Explicit Status OR Ready-but-missing-sources) console.log(`[MediaService] Shopify Media is Processing: ${d.file.getName()} (Status: ${match.status}, Sources: ${match.sources ? match.sources.length : 0})`); isProcessing = true; // Use Drive thumb as fallback if Shopify preview not ready if (!thumbnail) { try { const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`; if (nativeThumb.length > 100) thumbnail = nativeThumb; } catch(e) {} } } else { // 2. Native / Fallback try { // Try to get Drive thumbnail const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`; if (nativeThumb.length > 100) { // Check if valid (sometimes returns empty?) thumbnail = nativeThumb; } } catch (e) { // Processing / Error console.warn(`Failed to get native thumbnail for ${d.file.getName()}: ${e}`); isProcessing = true; // Assume processing thumbnail = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHdpZHRoPSI0OCIgdmlld0JveD0iMCAwIDQ4IDQ4Ij48cGF0aCBmaWxsPSIjNDI4NUY0IiBkPSJNMzYgOEgxMmMtMi4yMSAwLTQgMS43OS00IDR2MjRjMCAyLjIxIDEuNzkgNCA0IDRoMjRjMi4yMSAwIDQtMS43OSA0LTRWMTJjMC0yLjIxLTEuNzktNC00LTR6TTIwIDMxVjE3bDEyIDctMTIgN3oiLz48L3N2Zz4="; } } } unifiedState.push({ id: d.file.getId(), // Use Drive ID as primary key driveId: d.file.getId(), shopifyId: match ? match.id : null, filename: d.file.getName(), source: match ? 'synced' : 'drive_only', thumbnail: thumbnail, status: 'active', galleryOrder: d.galleryOrder, mimeType: d.file.getMimeType(), // Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL contentUrl: (match && match.sources) ? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url) : `https://drive.google.com/uc?export=download&id=${d.file.getId()}`, isProcessing: isProcessing }) }) // Find Shopify Orphans shopifyMedia.forEach(m => { if (!matchedShopifyIds.has(m.id)) { let mimeType = 'image/jpeg'; // Default let contentUrl = ""; if (m.mediaContentType === 'VIDEO' && m.sources) { // Find MP4 const mp4 = m.sources.find((s: any) => s.mimeType === 'video/mp4') if (mp4) { mimeType = mp4.mimeType contentUrl = mp4.url } } else if (m.mediaContentType === 'IMAGE' && m.image) { contentUrl = m.image.url } // Extract filename from URL (Shopify URLs usually contain the filename) let filename = "Orphaned Media"; try { if (contentUrl) { // Clean query params and get last segment const cleanUrl = contentUrl.split('?')[0]; const parts = cleanUrl.split('/'); const candidate = parts.pop(); if (candidate) filename = candidate; } } catch (e) { console.warn("Failed to extract filename from URL", e); } unifiedState.push({ id: m.id, // Use Shopify ID keys for orphans driveId: null, shopifyId: m.id, filename: filename, source: 'shopify_only', thumbnail: m.preview?.image?.originalSrc || "", status: 'active', galleryOrder: 10000, // End of list mimeType: mimeType, contentUrl: contentUrl }) } }) return unifiedState } linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) { console.log(`MediaService: Linking Drive File ${driveId} to Shopify Media ${shopifyId}`); // Verify ownership? Maybe later. For now, trust the ID. this.driveService.updateFileProperties(driveId, { shopify_media_id: shopifyId }); return { success: true }; } processMediaChanges(sku: string, finalState: any[], shopifyProductId: string): string[] { const logs: string[] = [] logs.push(`Starting processing for SKU ${sku}`) console.log(`MediaService: Processing changes for SKU ${sku}`) // 0. Service Availability Check & Local Capture (Fixing 'undefined' context issues) const shopifySvc = this.shopifyMediaService const driveSvc = this.driveService if (!shopifySvc) throw new Error("MediaService Error: shopifyMediaService is undefined") if (!driveSvc) throw new Error("MediaService Error: driveService is undefined") // 1. Get Current State (for diffing deletions) const currentState = this.getUnifiedMediaState(sku, shopifyProductId) const finalIds = new Set(finalState.map(f => f.id)) // 2. Process Deletions (Orphans not in final state are removed from Shopify) const toDelete = currentState.filter(c => !finalIds.has(c.id)) if (toDelete.length === 0) logs.push("No deletions found.") toDelete.forEach(item => { const msg = `Deleting item: ${item.filename}` logs.push(msg) console.log(msg) if (item.shopifyId) { shopifySvc.productDeleteMedia(shopifyProductId, item.shopifyId) logs.push(`- Deleted from Shopify (${item.shopifyId})`) } if (item.driveId) { // Check for Associated Sidecar Thumbs (Request #2) try { const f = driveSvc.getFileById(item.driveId); // We could inspect properties, or just try to find based on convention if we don't have props handy. // But `getUnifiedMediaState` logic shows we store `custom_thumbnail_id`. // However, `item` here comes from `getUnifiedMediaState`, but DOES IT include the custom prop? // Currently `unifiedState` items don't return `customThumbnailId` property explicitly in the Object. // We should probably fetch it or have included it. // Re-fetch props to be safe/clean. const props = driveSvc.getFileProperties(item.driveId); if (props && props['custom_thumbnail_id']) { driveSvc.trashFile(props['custom_thumbnail_id']); logs.push(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`); } } catch (ignore) { // If file already gone or other error } driveSvc.trashFile(item.driveId) logs.push(`- Trashed in Drive (${item.driveId})`) } }) // 3. Process Adoptions (Shopify Orphans -> Drive) // Identify items that are source='shopify_only' but are KEPT in the final state. // These need to be downloaded to become the source of truth in Drive. finalState.forEach(item => { if (item.source === 'shopify_only' && item.shopifyId) { const msg = `Adopting Orphan: ${item.filename}` logs.push(msg) console.log(msg) try { // Download const resp = this.networkService.fetch(item.thumbnail, { method: 'get' }) const blob = resp.getBlob() blob.setName(`${sku}_adopted_${Date.now()}.jpg`) // Safety rename const file = driveSvc.createFile(blob) // Move to correct folder const folder = driveSvc.getOrCreateFolder(sku, this.config.productPhotosFolderId) const driveFile = driveSvc.getFileById(file.getId()) // driveFile.moveTo(folder) // GAS Hack: make sure to add parents/remove parents if needed, or create in place // Mock/GAS adapter should handle folder placement correctly if possible, or we assume create puts in root and we move. // For this refactor, let's assume `createFile` puts it where it needs to be or we accept root for now. // ACTUALLY: The GASDriveService implementation uses DriveApp.createFile which puts in root. // We should move it strictly. folder.addFile(driveFile) DriveApp.getRootFolder().removeFile(driveFile) driveSvc.updateFileProperties(file.getId(), { shopify_media_id: item.shopifyId }) // Update item refs for subsequent steps item.driveId = file.getId() item.source = 'synced' logs.push(`- Adopted to Drive (${file.getId()})`) } catch (e) { logs.push(`- Failed to adopt ${item.filename}: ${e}`) } } }) // 4. Process Uploads (Drive Only -> Shopify) const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId) if (toUpload.length > 0) { const msg = `Uploading ${toUpload.length} new items from Drive` logs.push(msg) const uploads = toUpload.map(item => { const f = driveSvc.getFileById(item.driveId) return { filename: f.getName(), mimeType: f.getMimeType(), resource: f.getMimeType().startsWith('video/') ? "VIDEO" : "IMAGE", fileSize: f.getSize().toString(), httpMethod: "POST", file: f, originalItem: item } }) // ... (Existing upload logic logic, simplified for brevity in plan, but fully implemented here) // Batch Staged Uploads const stagedInput = uploads.map(u => ({ filename: u.filename, mimeType: u.mimeType, resource: u.resource, fileSize: u.fileSize, httpMethod: u.httpMethod })) const stagedResp = shopifySvc.stagedUploadsCreate(stagedInput) if (stagedResp.userErrors && stagedResp.userErrors.length > 0) { console.error("[MediaService] stagedUploadsCreate Errors:", JSON.stringify(stagedResp.userErrors)) logs.push(`- Upload preparation failed: ${stagedResp.userErrors.map(e => e.message).join(', ')}`) } const targets = stagedResp.stagedTargets const mediaToCreate = [] uploads.forEach((u, i) => { const target = targets[i] if (!target || !target.url) { logs.push(`- Failed to get upload target for ${u.filename}: Invalid target`) console.warn(`[MediaService] Missing target URL for ${u.filename}. Target:`, JSON.stringify(target)) return } const payload = {} target.parameters.forEach((p: any) => payload[p.name] = p.value) payload['file'] = u.file.getBlob() this.networkService.fetch(target.url, { method: "post", payload: payload }) mediaToCreate.push({ originalSource: target.resourceUrl, alt: u.filename, mediaContentType: u.resource }) }) const createdMedia = shopifySvc.productCreateMedia(shopifyProductId, mediaToCreate) if (createdMedia && createdMedia.media) { createdMedia.media.forEach((m: any, i: number) => { const originalItem = uploads[i].originalItem if (m.status === 'FAILED') { logs.push(`- Failed to create media for ${originalItem.filename}: ${m.message}`) return } if (m.id) { driveSvc.updateFileProperties(originalItem.driveId, { shopify_media_id: m.id }) originalItem.shopifyId = m.id originalItem.source = 'synced' logs.push(`- Created in Shopify (${m.id}) and linked`) } }) } } // 5. Sequential Reordering & Renaming // Now that we have Drive IDs and Shopify IDs for everything (orphans adopted, new files uploaded) // We update the gallery_order on ALL Drive files to match the finalState order (0-indexed). // And we check filenames. const reorderMoves: any[] = [] finalState.forEach((item, index) => { if (!item.driveId) return // Should not happen if adoption worked, but safety check try { const file = driveSvc.getFileById(item.driveId) // A. Update Gallery Order driveSvc.updateFileProperties(item.driveId, { gallery_order: index.toString() }) // B. Conditional Renaming const currentName = file.getName() const expectedPrefix = `${sku}_` // If name doesn't start with SKU_ or looks like "SKU_timestamp.ext" pattern enforcement // The requirement: "Files will only be renamed if they do not conform to the expected pattern" // Pattern: startWith sku + "_" if (!currentName.startsWith(expectedPrefix)) { const ext = currentName.includes('.') ? currentName.split('.').pop() : 'jpg' // Use file creation time or now for unique suffix const timestamp = new Date().getTime() const newName = `${sku}_${timestamp}.${ext}` driveSvc.renameFile(item.driveId, newName) logs.push(`- Renamed ${currentName} -> ${newName} (Non-conforming)`) } // C. Prepare Shopify Reorder if (item.shopifyId) { reorderMoves.push({ id: item.shopifyId, newPosition: index.toString() }) } } catch (e) { logs.push(`- Error updating ${item.filename}: ${e}`) } }) // 6. Execute Shopify Reorder if (reorderMoves.length > 0) { shopifySvc.productReorderMedia(shopifyProductId, reorderMoves) logs.push("Reordered media in Shopify.") } logs.push("Processing Complete.") return logs } }