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.
This commit is contained in:
Ben Miller
2026-01-03 08:05:44 -07:00
parent 1068c912dc
commit f3d8514e62
6 changed files with 318 additions and 161 deletions

View File

@ -1793,6 +1793,26 @@
if (diagnostics.shopify.status === 'ok') { if (diagnostics.shopify.status === 'ok') {
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success'); ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success');
ui.setShopifyLink(diagnostics.shopify.adminUrl); 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') { } else if (diagnostics.shopify.status === 'skipped') {
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info'); ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
} else { } else {

View File

@ -4,5 +4,7 @@ export interface IShopifyMediaService {
getProductMedia(productId: string): any[] getProductMedia(productId: string): any[]
productDeleteMedia(productId: string, mediaId: string): any productDeleteMedia(productId: string, mediaId: string): any
productReorderMedia(productId: string, moves: any[]): any productReorderMedia(productId: string, moves: any[]): any
getProduct(productId: string): any
getProductWithMedia(productId: string): any
getShopDomain(): string getShopDomain(): string
} }

View File

@ -20,7 +20,15 @@ const mockShopify = {
productCreateMedia: jest.fn(), productCreateMedia: jest.fn(),
productDeleteMedia: jest.fn(), productDeleteMedia: jest.fn(),
productReorderMedia: 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 = { const mockNetwork = {
fetch: jest.fn(), fetch: jest.fn(),

View File

@ -42,7 +42,7 @@ export class MediaService {
private fetchRawData(sku: string, shopifyProductId: string) { private fetchRawData(sku: string, shopifyProductId: string) {
const result = { const result = {
drive: { folder: null, files: [], error: null, folderUrl: null }, drive: { folder: null, files: [], error: null, folderUrl: null },
shopify: { media: [], error: null } shopify: { media: [], product: null, error: null }
}; };
// 1. Unsafe Drive Check // 1. Unsafe Drive Check
@ -59,7 +59,11 @@ export class MediaService {
// 2. Unsafe Shopify Check // 2. Unsafe Shopify Check
if (shopifyProductId) { if (shopifyProductId) {
try { 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) { } catch (e) {
result.shopify.error = e; result.shopify.error = e;
} }
@ -71,7 +75,7 @@ export class MediaService {
getDiagnostics(sku: string, shopifyProductId: string, rawData?: any) { getDiagnostics(sku: string, shopifyProductId: string, rawData?: any) {
const results = { const results = {
drive: { status: 'pending', fileCount: 0, folderId: null, folderUrl: null, error: null }, 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 }, matching: { status: 'pending', error: null },
activeJobId: null activeJobId: null
} }
@ -111,6 +115,14 @@ export class MediaService {
// Admin URL construction (Best effort) // Admin URL construction (Best effort)
const domain = this.shopifyMediaService.getShopDomain ? this.shopifyMediaService.getShopDomain() : 'admin.shopify.com'; 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.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'; results.shopify.status = 'ok';
} }
} else { } else {
@ -120,7 +132,7 @@ export class MediaService {
return results; 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}`); console.log(`MediaService: Getting unified state for SKU ${sku}`);
const data = rawData || this.fetchRawData(sku, shopifyProductId); const data = rawData || this.fetchRawData(sku, shopifyProductId);
@ -164,26 +176,6 @@ export class MediaService {
return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId } 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 // Sort: Gallery Order ASC, then Filename ASC
driveFileStats.sort((a, b) => { driveFileStats.sort((a, b) => {
@ -193,156 +185,201 @@ export class MediaService {
return a.file.getName().localeCompare(b.file.getName()); return a.file.getName().localeCompare(b.file.getName());
}); });
if (!skipThumbnails) {
// Batch Status Check for Videos with Sidecars // Populate Sidecar Map
const videoStatusMap = new Map<string, any>(); driveFileStats.forEach(stat => {
// Identify videos that MIGHT be ready (have sidecar) if (stat.type === 'thumbnail' && stat.parentVideoId) {
const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId())); sidecarFileIds.add(stat.file.getId());
// URL-based approach failed (CORS/Auth).
if (videosToCheck.length > 0 && typeof Drive !== 'undefined') { // Switch to Server-Side Base64 encoding (Robust).
try { try {
// Check status for ALL videos in folder. Easier than filtering by specific IDs in 'q' which has length limits. // Fetch the bytes of the JPEG sidecar
// We assume the folder ID is valid. // We use getThumbnail() here because identical to getBlob().getBytes() for images,
const folderId = data.drive.folder ? data.drive.folder.getId() : null; // but getThumbnail() is sometimes optimized/cached by DriveApp?
if (folderId) { // actually getBlob() is safer for the "original" sidecar content.
// @ts-ignore const bytes = stat.file.getBlob().getBytes();
const response = Drive.Files.list({ const b64 = Utilities.base64Encode(bytes);
q: `'${folderId}' in parents and mimeType contains 'video/' and trashed = false`, const dataUrl = `data:image/jpeg;base64,${b64}`;
fields: 'files(id, hasThumbnail, thumbnailLink, videoMediaMetadata)' sidecarThumbMap.set(stat.parentVideoId, dataUrl);
}); } catch (e) {
if (response.files) { console.warn(`[MediaService] Failed to read sidecar file ${stat.file.getName()}: ${e}`);
response.files.forEach((f: any) => videoStatusMap.set(f.id, f));
} }
} }
} catch (e) { });
console.warn("[MediaService] Batch video status check failed", e);
}
}
// Match Logic (Strict ID Match Only) // Batch Status Check for Videos with Sidecars
driveFileStats.forEach(d => { const videoStatusMap = new Map<string, any>();
// Skip Sidecar Files in main list // Identify videos that MIGHT be ready (have sidecar)
if (sidecarFileIds.has(d.file.getId())) return; const videosToCheck = driveFileStats.filter(d => sidecarThumbMap.has(d.file.getId()));
let match = null if (videosToCheck.length > 0 && typeof Drive !== 'undefined') {
let isProcessing = false try {
let thumbnail = ""; // 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.
// 1. ID Match const folderId = data.drive.folder ? data.drive.folder.getId() : null;
if (d.shopifyId) { if (folderId) {
match = shopifyMedia.find(m => m.id === d.shopifyId) // @ts-ignore
if (match) matchedShopifyIds.add(match.id) 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 // Match Logic (Strict ID Match Only)
if (match && match.preview && match.preview.image && match.preview.image.originalSrc) { driveFileStats.forEach(d => {
thumbnail = match.preview.image.originalSrc; // Skip Sidecar Files in main list
} else { if (sidecarFileIds.has(d.file.getId())) return;
// Drive Thumbnail Strategy
// Determine if Native Drive Thumbnail is ready/valid
let nativeThumbReady = false;
let nativeThumbUrl = "";
try { let match = null
// We assume if getThumbnail() succeeds and returns "substantial" data, it's ready. let isProcessing = false
// Or check availability of thumbnailLink if we had used Advanced API. let thumbnail = "";
// 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 // 1. ID Match
const sidecarId = d.customThumbnailId; // Direct lookup from properties if (d.shopifyId) {
if (sidecarId) { match = shopifyMedia.find(m => m.id === d.shopifyId)
try { if (match) matchedShopifyIds.add(match.id)
this.driveService.trashFile(sidecarId); }
sidecarFileIds.delete(sidecarId);
sidecarThumbMap.delete(d.file.getId()); // Thumbnail Logic
console.log(`[MediaService] Trashed sidecar ${sidecarId}`); if (match && match.preview && match.preview.image && match.preview.image.originalSrc) {
} catch (trashErr) { thumbnail = match.preview.image.originalSrc;
console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr); } 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) { } catch (e) {
// Processing / Error // Ignore individual file errors
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({ // 1. Check Sidecar (If it still exists after potential cleanup)
id: d.file.getId(), // Use Drive ID as primary key if (sidecarThumbMap.has(d.file.getId())) {
driveId: d.file.getId(), console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
shopifyId: match ? match.id : null, thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
filename: d.file.getName(), isProcessing = true; // SHOW HOURGLASS (Request #3)
source: match ? 'synced' : 'drive_only', } else if (match && (
thumbnail: thumbnail, match.status === 'PROCESSING' ||
status: 'active', match.status === 'UPLOADED' ||
galleryOrder: d.galleryOrder, (match.mediaContentType === 'VIDEO' && (!match.sources || match.sources.length === 0) && match.status !== 'FAILED')
mimeType: d.file.getMimeType(), )) {
// Prefer Shopify Video URL for playback/hover if available, otherwise Drive Download URL // Shopify Processing (Explicit Status OR Ready-but-missing-sources)
contentUrl: (match && match.sources) console.log(`[MediaService] Shopify Media is Processing: ${d.file.getName()} (Status: ${match.status}, Sources: ${match.sources ? match.sources.length : 0})`);
? (match.sources.find((s: any) => s.mimeType === 'video/mp4')?.url || match.sources[0]?.url) isProcessing = true;
: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`, // Use Drive thumb as fallback if Shopify preview not ready
isProcessing: isProcessing 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 // Find Shopify Orphans
shopifyMedia.forEach(m => { shopifyMedia.forEach(m => {
@ -403,7 +440,7 @@ export class MediaService {
calculatePlan(sku: string, finalState: any[], shopifyProductId: string) { calculatePlan(sku: string, finalState: any[], shopifyProductId: string) {
// 1. Get Current State // 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)); const finalIds = new Set(finalState.map(f => f.id));
// 2. Identify Deletions // 2. Identify Deletions

View File

@ -33,6 +33,22 @@ export class MockShopifyMediaService implements IShopifyMediaService {
return [] 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 { productDeleteMedia(productId: string, mediaId: string): any {
return { return {
productDeleteMedia: { productDeleteMedia: {

View File

@ -106,6 +106,80 @@ export class ShopifyMediaService implements IShopifyMediaService {
return response.content.data.product.media.edges.map((edge: any) => edge.node) 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 { productDeleteMedia(productId: string, mediaId: string): any {
const query = /* GraphQL */ ` const query = /* GraphQL */ `
mutation productDeleteMedia($mediaIds: [ID!]!, $productId: ID!) { mutation productDeleteMedia($mediaIds: [ID!]!, $productId: ID!) {