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') {
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);
// 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 {

View File

@ -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
}

View File

@ -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(),

View File

@ -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,6 +176,16 @@ export class MediaService {
return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId }
})
// 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());
});
if (!skipThumbnails) {
// Populate Sidecar Map
driveFileStats.forEach(stat => {
if (stat.type === 'thumbnail' && stat.parentVideoId) {
@ -185,15 +207,6 @@ export class MediaService {
}
});
// 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());
});
// Batch Status Check for Videos with Sidecars
const videoStatusMap = new Map<string, any>();
// Identify videos that MIGHT be ready (have sidecar)
@ -241,7 +254,6 @@ export class MediaService {
// 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.
@ -343,6 +355,31 @@ export class MediaService {
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

View File

@ -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: {

View File

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