From f3d8514e622c8731d10cacc77b4885f306232c67 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Sat, 3 Jan 2026 08:05:44 -0700 Subject: [PATCH] Optimize Media Planning by skipping thumbnail generation This change modifies the validation/planning phase to skip the expensive thumbnail generation step in 'getUnifiedMediaState'. Since the planning phase primarily needs file IDs and names to calculate deletions, adoptions, and reorders, skipping the thumbnail verification/retrieval (including sidecar checks) significantly reduces the latency of the 'Save Changes' operation. --- src/MediaManager.html | 20 ++ src/interfaces/IShopifyMediaService.ts | 2 + src/mediaManager.integration.test.ts | 10 +- src/services/MediaService.ts | 357 +++++++++++++----------- src/services/MockShopifyMediaService.ts | 16 ++ src/services/ShopifyMediaService.ts | 74 +++++ 6 files changed, 318 insertions(+), 161 deletions(-) diff --git a/src/MediaManager.html b/src/MediaManager.html index 6eec733..8e920a2 100644 --- a/src/MediaManager.html +++ b/src/MediaManager.html @@ -1793,6 +1793,26 @@ if (diagnostics.shopify.status === 'ok') { ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) Open Admin ↗`, 'success'); ui.setShopifyLink(diagnostics.shopify.adminUrl); + + // BooLouMud Link + if (diagnostics.shopify.onlineStoreUrl) { + var quickLinks = document.getElementById('quick-links'); + // Check if link already exists (via ID?) or just append if empty. + // Since loadMedia clears/resets UI state usually, but quick-links might persist? + // Actually quick-links is empty on load? No, let's clear it first to be safe or check. + // But ui.setShopifyLink might use it? + // Let's perform a direct safe inject. + if (quickLinks && !document.getElementById('booloumud-link')) { + var link = document.createElement('a'); + link.id = 'booloumud-link'; + link.href = diagnostics.shopify.onlineStoreUrl; + link.target = '_blank'; + link.style.textDecoration = 'none'; + link.style.color = 'var(--primary)'; + link.innerHTML = 'BooLouMud ↗'; + quickLinks.appendChild(link); + } + } } else if (diagnostics.shopify.status === 'skipped') { ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info'); } else { diff --git a/src/interfaces/IShopifyMediaService.ts b/src/interfaces/IShopifyMediaService.ts index e8436fa..f5a5f00 100644 --- a/src/interfaces/IShopifyMediaService.ts +++ b/src/interfaces/IShopifyMediaService.ts @@ -4,5 +4,7 @@ export interface IShopifyMediaService { getProductMedia(productId: string): any[] productDeleteMedia(productId: string, mediaId: string): any productReorderMedia(productId: string, moves: any[]): any + getProduct(productId: string): any + getProductWithMedia(productId: string): any getShopDomain(): string } diff --git a/src/mediaManager.integration.test.ts b/src/mediaManager.integration.test.ts index 27fb663..dfde656 100644 --- a/src/mediaManager.integration.test.ts +++ b/src/mediaManager.integration.test.ts @@ -20,7 +20,15 @@ const mockShopify = { productCreateMedia: jest.fn(), productDeleteMedia: jest.fn(), productReorderMedia: jest.fn(), - stagedUploadsCreate: jest.fn() + stagedUploadsCreate: jest.fn(), + getProductWithMedia: jest.fn().mockImplementation(() => { + // Delegate to specific mocks if set, otherwise default + const media = mockShopify.getProductMedia() || []; + return { + product: { id: "gid://shopify/Product/123", title: "Mock Product", handle: "mock-product", onlineStoreUrl: "" }, + media: media + } + }) } const mockNetwork = { fetch: jest.fn(), diff --git a/src/services/MediaService.ts b/src/services/MediaService.ts index 8e7e027..886ee5f 100644 --- a/src/services/MediaService.ts +++ b/src/services/MediaService.ts @@ -42,7 +42,7 @@ export class MediaService { private fetchRawData(sku: string, shopifyProductId: string) { const result = { drive: { folder: null, files: [], error: null, folderUrl: null }, - shopify: { media: [], error: null } + shopify: { media: [], product: null, error: null } }; // 1. Unsafe Drive Check @@ -59,7 +59,11 @@ export class MediaService { // 2. Unsafe Shopify Check if (shopifyProductId) { try { - result.shopify.media = this.shopifyMediaService.getProductMedia(shopifyProductId); + const combined = this.shopifyMediaService.getProductWithMedia(shopifyProductId); + if (combined) { + result.shopify.media = combined.media; + result.shopify.product = combined.product; + } } catch (e) { result.shopify.error = e; } @@ -71,7 +75,7 @@ export class MediaService { getDiagnostics(sku: string, shopifyProductId: string, rawData?: any) { const results = { drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null }, - shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, error: null }, + shopify: { status: 'pending', mediaCount: 0, id: shopifyProductId, adminUrl: null, onlineStoreUrl: null, error: null }, matching: { status: 'pending', error: null }, activeJobId: null } @@ -111,6 +115,14 @@ export class MediaService { // Admin URL construction (Best effort) 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()}`; + + // Online Store URL logic + if (data.shopify.product && data.shopify.product.onlineStoreUrl) { + results.shopify.onlineStoreUrl = data.shopify.product.onlineStoreUrl; + } else if (data.shopify.product && data.shopify.product.handle) { + results.shopify.onlineStoreUrl = `https://${domain}/products/${data.shopify.product.handle}`; + } + results.shopify.status = 'ok'; } } else { @@ -120,7 +132,7 @@ export class MediaService { return results; } - getUnifiedMediaState(sku: string, shopifyProductId: string, rawData?: any): any[] { + getUnifiedMediaState(sku: string, shopifyProductId: string, rawData?: any, skipThumbnails: boolean = false): any[] { console.log(`MediaService: Getting unified state for SKU ${sku}`); const data = rawData || this.fetchRawData(sku, shopifyProductId); @@ -164,26 +176,6 @@ export class MediaService { 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) => { @@ -193,156 +185,201 @@ export class MediaService { return a.file.getName().localeCompare(b.file.getName()); }); - - // Batch Status Check for Videos with Sidecars - const videoStatusMap = new Map(); - // Identify videos that MIGHT be ready (have sidecar) - const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId())); - - if (videosToCheck.length > 0 && typeof Drive !== 'undefined') { - try { - // Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits. - // We assume the folder ID is valid. - const folderId = data.drive.folder ? data.drive.folder.getId() : null; - if (folderId) { - // @ts-ignore - const response = Drive.Files.list({ - q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`, - fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)' - }); - if (response.files) { - response.files.forEach((f: any) => videoStatusMap.set(f.id, f)); + if (!skipThumbnails) { + // 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}`); } } - } catch (e) { - console.warn("[MediaService] Batch video status check failed", e); - } - } + }); - // Match Logic (Strict ID Match Only) - driveFileStats.forEach(d => { - // Skip Sidecar Files in main list - if (sidecarFileIds.has(d.file.getId())) return; + // Batch Status Check for Videos with Sidecars + const videoStatusMap = new Map(); + // Identify videos that MIGHT be ready (have sidecar) + const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId())); - 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) + if (videosToCheck.length > 0 && typeof Drive !== 'undefined') { + try { + // Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits. + // We assume the folder ID is valid. + const folderId = data.drive.folder ? data.drive.folder.getId() : null; + if (folderId) { + // @ts-ignore + const response = Drive.Files.list({ + q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`, + fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)' + }); + if (response.files) { + response.files.forEach((f: any) => videoStatusMap.set(f.id, f)); + } + } + } catch (e) { + console.warn("[MediaService] Batch video status check failed", e); + } } - // 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 = ""; + // Match Logic (Strict ID Match Only) + driveFileStats.forEach(d => { + // Skip Sidecar Files in main list + if (sidecarFileIds.has(d.file.getId())) return; - 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. - // Batch Optimized Check - if (videoStatusMap.has(d.file.getId())) { - const meta = videoStatusMap.get(d.file.getId()); - // Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid.. - // Check `videoMediaMetadata.width` to ensure processing is complete (width is often missing during processing) - if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) { - // SUCCESS: Drive has finished processing. - nativeThumbReady = true; - console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`); + let match = null + let isProcessing = false + let thumbnail = ""; - // Cleanup Sidecar - const sidecarId = d.customThumbnailId; // Direct lookup from properties - if (sidecarId) { - try { - this.driveService.trashFile(sidecarId); - sidecarFileIds.delete(sidecarId); - sidecarThumbMap.delete(d.file.getId()); - console.log(`[MediaService] Trashed sidecar ${sidecarId}`); - } catch (trashErr) { - console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr); - } + // 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; + + 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. + // Batch Optimized Check + if (videoStatusMap.has(d.file.getId())) { + const meta = videoStatusMap.get(d.file.getId()); + // Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid.. + // Check `videoMediaMetadata.width` to ensure processing is complete (width is often missing during processing) + if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) { + // SUCCESS: Drive has finished processing. + nativeThumbReady = true; + console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`); + + // Cleanup Sidecar + const sidecarId = d.customThumbnailId; // Direct lookup from properties + if (sidecarId) { + try { + this.driveService.trashFile(sidecarId); + sidecarFileIds.delete(sidecarId); + 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 individual file errors - } - - // 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) { /* ignore thumbnail generation error */ } - } - } 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="; + // Ignore individual file errors } - } - } - 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 + // 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) { /* ignore thumbnail generation error */ } + } + } 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 + }) }) - }) + } else { + // Skip Thumbnails Logic (Fast Path) + driveFileStats.forEach(d => { + // Minimal State for Planning + let match = null + if (d.shopifyId) { + match = shopifyMedia.find(m => m.id === d.shopifyId) + if (match) matchedShopifyIds.add(match.id) + } + + unifiedState.push({ + id: d.file.getId(), + driveId: d.file.getId(), + shopifyId: match ? match.id : null, + filename: d.file.getName(), + source: match ? 'synced' : 'drive_only', + thumbnail: "", // Skipped + status: 'active', + galleryOrder: d.galleryOrder, + mimeType: d.file.getMimeType(), + contentUrl: "", // Skipped + isProcessing: false + }) + }); + } // Find Shopify Orphans shopifyMedia.forEach(m => { @@ -403,7 +440,7 @@ export class MediaService { calculatePlan(sku: string, finalState: any[], shopifyProductId: string) { // 1. Get Current State - const currentState = this.getUnifiedMediaState(sku, shopifyProductId); + const currentState = this.getUnifiedMediaState(sku, shopifyProductId, undefined, true); const finalIds = new Set(finalState.map(f => f.id)); // 2. Identify Deletions diff --git a/src/services/MockShopifyMediaService.ts b/src/services/MockShopifyMediaService.ts index 24a84dc..91eb84b 100644 --- a/src/services/MockShopifyMediaService.ts +++ b/src/services/MockShopifyMediaService.ts @@ -33,6 +33,22 @@ export class MockShopifyMediaService implements IShopifyMediaService { return [] } + getProduct(productId: string): any { + return { + id: productId, + title: "Mock Product", + handle: "mock-product", + onlineStoreUrl: "https://mock-shop.myshopify.com/products/mock-product" + } + } + + getProductWithMedia(productId: string): any { + return { + product: this.getProduct(productId), + media: this.getProductMedia(productId) + }; + } + productDeleteMedia(productId: string, mediaId: string): any { return { productDeleteMedia: { diff --git a/src/services/ShopifyMediaService.ts b/src/services/ShopifyMediaService.ts index 0867c44..2604a39 100644 --- a/src/services/ShopifyMediaService.ts +++ b/src/services/ShopifyMediaService.ts @@ -106,6 +106,80 @@ export class ShopifyMediaService implements IShopifyMediaService { return response.content.data.product.media.edges.map((edge: any) => edge.node) } + getProduct(productId: string): any { + const query = /* GraphQL */ ` + query getProduct($productId: ID!) { + product(id: $productId) { + id + title + handle + onlineStoreUrl + } + } + ` + const variables = { productId } + const payload = buildGqlQuery(query, variables) + const response = this.shop.shopifyGraphQLAPI(payload) + if (!response || !response.content || !response.content.data || !response.content.data.product) { + console.warn("getProduct: Product not found or access denied for ID:", productId); + return null; + } + return response.content.data.product + } + + getProductWithMedia(productId: string): any { + const query = /* GraphQL */ ` + query getProductWithMedia($productId: ID!) { + product(id: $productId) { + id + title + handle + onlineStoreUrl + media(first: 250) { + edges { + node { + id + alt + mediaContentType + status + preview { + image { + originalSrc + } + } + ... on Video { + sources { + url + mimeType + } + } + ... on MediaImage { + image { + url + } + } + } + } + } + } + } + ` + const variables = { productId } + const payload = buildGqlQuery(query, variables) + const response = this.shop.shopifyGraphQLAPI(payload) + if (!response || !response.content || !response.content.data || !response.content.data.product) { + console.warn("getProductWithMedia: Product not found or access denied for ID:", productId); + return null; + } + + // Normalize return structure to match expectations + const p = response.content.data.product; + return { + product: { id: p.id, title: p.title, handle: p.handle, onlineStoreUrl: p.onlineStoreUrl }, + media: p.media.edges.map((edge: any) => edge.node) + }; + } + productDeleteMedia(productId: string, mediaId: string): any { const query = /* GraphQL */ ` mutation productDeleteMedia($mediaIds: [ID!]!, $productId: ID!) {