feat: backend implementation for media manager v2 (WIP - Undeployed)

This commit is contained in:
Ben Miller
2025-12-28 08:13:27 -07:00
parent a9cb63fd67
commit 6e1222cec9
11 changed files with 763 additions and 189 deletions

View File

@ -21,83 +21,230 @@ export class MediaService {
this.config = config
}
syncMediaForSku(sku: string, shopifyProductId: string) {
console.log(`MediaService: Syncing media for SKU ${sku}`)
// 1. Get files from Drive
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)
const files = this.driveService.getFiles(folder.getId())
const driveFiles = this.driveService.getFiles(folder.getId())
if (files.length === 0) {
console.log("No files found in Drive.")
return
}
console.log(`Found ${files.length} files in Drive folder ${folder.getId()}`)
// Sort files by name to ensure consistent order (01.jpg, 02.jpg)
files.sort((a, b) => a.getName().localeCompare(b.getName()))
// TODO: optimization - check if file already exists on Shopify by filename/size/hash
// For now, we will just upload everything that is new, or we rely on Shopify to dedupe?
// Shopify does NOT dedupe automatically if we create new media entries.
// We should probably list current media on the product and compare filenames.
// But filenames in Shopify are sanitized.
// Pro trick: Use 'alt' text to store the original filename/Drive ID.
// 2. Prepare Staged Uploads
// collecting files needing upload
const filesToUpload = files; // uploading all for MVP simplicity, assume clean state or overwrite logic later
if (filesToUpload.length === 0) return
const stagedUploadInput = filesToUpload.map(f => ({
filename: f.getName(),
mimeType: f.getMimeType(),
resource: "IMAGE", // or VIDEO
httpMethod: "POST"
}))
const response = this.shopifyMediaService.stagedUploadsCreate(stagedUploadInput)
if (response.userErrors && response.userErrors.length > 0) {
console.error("Staged upload errors:", response.userErrors)
throw new Error("Staged upload failed")
// 2. Get Shopify Media
let shopifyMedia: any[] = []
if (shopifyProductId) {
shopifyMedia = this.shopifyMediaService.getProductMedia(shopifyProductId)
}
const stagedTargets = response.stagedTargets
// 3. Match
const unifiedState: any[] = []
const matchedShopifyIds = new Set<string>()
if (!stagedTargets || stagedTargets.length !== filesToUpload.length) {
throw new Error("Failed to create staged upload targets")
// Map of Drive Files
const driveMap = new Map<string, {file: GoogleAppsScript.Drive.File, shopifyId: string | null}>()
driveFiles.forEach(f => {
let shopifyId = null
try {
// Expensive lookup for properties:
if (typeof Drive !== 'undefined') {
const advFile = (Drive as any).Files.get(f.getId(), { fields: 'appProperties' })
if (advFile.appProperties && advFile.appProperties['shopify_media_id']) {
shopifyId = advFile.appProperties['shopify_media_id']
}
}
} catch (e) {
console.warn(`Failed to get properties for ${f.getName()}`)
}
driveMap.set(f.getId(), { file: f, shopifyId })
})
// Match Logic
driveFiles.forEach(f => {
const d = driveMap.get(f.getId())
if (!d) return
let match = null
// 1. ID Match
if (d.shopifyId) {
match = shopifyMedia.find(m => m.id === d.shopifyId)
if (match) matchedShopifyIds.add(match.id)
}
// 2. Filename Match (if no ID match)
if (!match) {
match = shopifyMedia.find(m =>
!matchedShopifyIds.has(m.id) &&
(m.filename === f.getName() || (m.preview && m.preview.image && m.preview.image.originalSrc && m.preview.image.originalSrc.includes(f.getName())))
)
if (match) matchedShopifyIds.add(match.id)
}
unifiedState.push({
id: f.getId(), // Use Drive ID as primary key for "Synced" or "Drive" items
driveId: f.getId(),
shopifyId: match ? match.id : null,
filename: f.getName(),
source: match ? 'synced' : 'drive_only',
thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(f.getThumbnail().getBytes())}`, // Expensive?
status: 'active'
})
})
// Find Shopify Orphans
shopifyMedia.forEach(m => {
if (!matchedShopifyIds.has(m.id)) {
unifiedState.push({
id: m.id, // Use Shopify ID keys for orphans
driveId: null,
shopifyId: m.id,
filename: "Shopify Media", // TODO: extract real name
source: 'shopify_only',
thumbnail: m.preview?.image?.originalSrc || "",
status: 'active'
})
}
})
return unifiedState
}
processMediaChanges(sku: string, finalState: any[], shopifyProductId: string) {
console.log(`MediaService: Processing changes for SKU ${sku}`)
// 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
const toDelete = currentState.filter(c => !finalIds.has(c.id))
toDelete.forEach(item => {
console.log(`Deleting item: ${item.filename}`)
if (item.shopifyId) {
this.shopifyMediaService.productDeleteMedia(shopifyProductId, item.shopifyId)
}
if (item.driveId) {
this.driveService.trashFile(item.driveId)
}
})
// 3. Process Backfills (Shopify Only -> Drive)
finalState.forEach(item => {
if (item.source === 'shopify_only' && item.shopifyId) {
console.log(`Backfilling item: ${item.filename}`)
// Download using global UrlFetchApp for blob access if generic interface is limited?
// Actually implementation of INetworkService returns HTTPResponse which has getBlob().
// But item.thumbnail usually is a URL.
const resp = this.networkService.fetch(item.thumbnail, { method: 'get' })
const blob = resp.getBlob()
const file = this.driveService.createFile(blob)
// Move to correct folder
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
const driveFile = this.driveService.getFileById(file.getId())
// GASDriveService must handle move? Standard File has moveTo?
// "moveTo" is standard GAS.
// But we used interface `IDriveService` which returns `GoogleAppsScript.Drive.File`.
// So we can assume `driveFile.moveTo(folder)` works if it's a real GAS object.
// TypeScript might complain if `IDriveService` returns a wrapper?
// Interface says `GoogleAppsScript.Drive.File`. That is the native type.
// Native type has `moveTo(destination: Folder)`.
driveFile.moveTo(folder)
this.driveService.updateFileProperties(file.getId(), { shopify_media_id: item.shopifyId })
// Update item refs for subsequent steps
item.driveId = file.getId()
item.source = 'synced'
}
})
// 4. Process Uploads (Drive Only -> Shopify)
const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId)
if (toUpload.length > 0) {
console.log(`Uploading ${toUpload.length} new items from Drive`)
const uploads = toUpload.map(item => {
const f = this.driveService.getFileById(item.driveId)
return {
filename: f.getName(),
mimeType: f.getMimeType(),
resource: "IMAGE",
httpMethod: "POST",
file: f
}
})
const stagedInput = uploads.map(u => ({
filename: u.filename,
mimeType: u.mimeType,
resource: u.resource,
httpMethod: u.httpMethod
}))
const stagedResp = this.shopifyMediaService.stagedUploadsCreate(stagedInput)
const targets = stagedResp.stagedTargets
const mediaToCreate = []
uploads.forEach((u, i) => {
const target = targets[i]
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: "IMAGE"
})
})
// Create Media (Updated to return IDs)
const createdMedia = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
if (createdMedia && createdMedia.media) {
createdMedia.media.forEach((m: any, i: number) => {
const originalItem = toUpload[i]
if (m.status === 'FAILED') {
console.error("Media create failed", m)
return
}
if (m.id) {
this.driveService.updateFileProperties(originalItem.driveId, { shopify_media_id: m.id })
originalItem.shopifyId = m.id
originalItem.source = 'synced'
}
})
}
}
const mediaToCreate = []
// 3. Upload files to Targets
for (let i = 0; i < filesToUpload.length; i++) {
const file = filesToUpload[i]
const target = stagedTargets[i]
console.log(`Uploading ${file.getName()} to ${target.url}`)
const payload = {}
target.parameters.forEach(p => payload[p.name] = p.value)
payload['file'] = file.getBlob()
this.networkService.fetch(target.url, {
method: "post",
payload: payload
})
mediaToCreate.push({
originalSource: target.resourceUrl,
alt: file.getName(), // Storing filename in Alt for basic deduping later
mediaContentType: "IMAGE" // TODO: Detect video
})
// 5. Process Reordering
const moves: any[] = []
finalState.forEach((item, index) => {
if (item.shopifyId) {
moves.push({ id: item.shopifyId, newPosition: index.toString() })
}
})
if (moves.length > 0) {
this.shopifyMediaService.productReorderMedia(shopifyProductId, moves)
}
// 4. Create Media on Shopify
console.log("Creating media on Shopify...")
const result = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
console.log("Media created successfully")
// 6. Rename Drive Files
finalState.forEach((item, index) => {
if (item.driveId) {
const paddedIndex = (index + 1).toString().padStart(4, '0')
const ext = item.filename.includes('.') ? item.filename.split('.').pop() : 'jpg'
const newName = `${sku}_${paddedIndex}.${ext}`
// Avoid unnecessary rename validation calls if possible, but renameFile is fast
const startName = this.driveService.getFileById(item.driveId).getName()
if (startName !== newName) {
this.driveService.renameFile(item.driveId, newName)
}
}
})
}
}