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!) {