Files
product_inventory/src/mediaHandlers.ts
Ben Miller f1ab3b7b84 feat: Add custom video thumbnails for Drive uploads
- Implemented custom thumbnail injection in GASDriveService.getResumableUploadUrl.
- Fetches thumbnails from Google Photos using w320 size to avoid API limits.
- Added strict < 2MB size check for thumbnails.
- Updated mediaHandlers and MediaManager to pass sourceUrl to the backend.
- This allows Drive to display a visual cue immediately for video files still processing.
2025-12-30 00:38:57 -07:00

517 lines
20 KiB
TypeScript

import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
import { GASDriveService } from "./services/GASDriveService"
import { ShopifyMediaService } from "./services/ShopifyMediaService"
import { GASNetworkService } from "./services/GASNetworkService"
import { MediaService } from "./services/MediaService"
import { Shop } from "./shopifyApi"
import { Config } from "./config"
import { Product } from "./Product"
export function showMediaManager() {
const html = HtmlService.createHtmlOutputFromFile("MediaManager")
.setTitle("Media Manager")
.setWidth(1100)
.setHeight(750);
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
}
export function getSelectedProductInfo(): { sku: string, title: string } | null {
const ss = new GASSpreadsheetService()
const sheet = SpreadsheetApp.getActiveSheet()
if (sheet.getName() !== "product_inventory") return null
const row = sheet.getActiveRange().getRow()
if (row <= 1) return null // Header
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
const title = ss.getCellValueByColumnName("product_inventory", row, "title")
return sku ? { sku: String(sku), title: String(title || "") } : null
}
export function getPickerConfig() {
const config = new Config()
return {
apiKey: config.googlePickerApiKey,
token: ScriptApp.getOAuthToken(),
email: Session.getEffectiveUser().getEmail(),
parentId: config.productPhotosFolderId // Root folder to start picker in? Optionally could be SKU folder
}
}
export function getMediaForSku(sku: string): any[] {
const config = new Config()
const driveService = new GASDriveService()
const shop = new Shop()
const shopifyMediaService = new ShopifyMediaService(shop)
const networkService = new GASNetworkService()
const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config)
// Resolve Product ID (Best Effort)
const product = new Product(sku)
// Ensure we have the latest correct ID from Shopify, repairing the sheet if needed
try {
product.MatchToShopifyProduct(shop);
} catch (e) {
console.warn("MatchToShopifyProduct failed", e);
}
const shopifyId = product.shopify_id || ""
return mediaService.getUnifiedMediaState(sku, shopifyId)
}
export function saveMediaChanges(sku: string, finalState: any[]) {
const config = new Config()
const driveService = new GASDriveService()
const shop = new Shop()
const shopifyMediaService = new ShopifyMediaService(shop)
const networkService = new GASNetworkService()
const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config)
const product = new Product(sku)
// Ensure we have the latest correct ID from Shopify
try {
product.MatchToShopifyProduct(shop);
} catch (e) {
console.warn("MatchToShopifyProduct failed", e);
}
if (!product.shopify_id) {
// Allow saving Drive-only changes? No, we need Shopify context for "Staging" usually.
// But if we just rename drive files, we could?
// For now, fail safe.
throw new Error("Product must be synced to Shopify before saving media changes.")
}
return mediaService.processMediaChanges(sku, finalState, product.shopify_id)
}
export function getMediaDiagnostics(sku: string) {
const config = new Config()
const driveService = new GASDriveService()
const shop = new Shop()
const shopifyMediaService = new ShopifyMediaService(shop)
const networkService = new GASNetworkService()
const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config)
// Resolve Product ID
const product = new Product(sku)
// Ensure we have the latest correct ID from Shopify
try {
product.MatchToShopifyProduct(shop);
} catch (e) {
console.warn("MatchToShopifyProduct failed", e);
}
const shopifyId = product.shopify_id || ""
const diagnostics = mediaService.getDiagnostics(sku, shopifyId)
// Inject OAuth token for frontend video streaming (Drive API alt=media)
return {
...diagnostics,
token: ScriptApp.getOAuthToken()
}
}
export function linkDriveFileToShopifyMedia(sku: string, driveId: string, shopifyId: string) {
const config = new Config()
const driveService = new GASDriveService()
const shop = new Shop()
const shopifyMediaService = new ShopifyMediaService(shop)
const networkService = new GASNetworkService()
const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config)
return mediaService.linkDriveFileToShopifyMedia(sku, driveId, shopifyId)
}
// NEW: Resumable Upload Ticket
export function getUploadUrl(sku: string, filename: string, mimeType: string, sourceUrl?: string) {
const config = new Config()
const driveService = new GASDriveService()
// Ensure folder exists and get ID
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
// Generate Ticket
return driveService.getResumableUploadUrl(filename, mimeType, folder.getId(), sourceUrl)
}
// Deprecated (but kept for fallback/legacy small files if needed)
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
console.warn("Using legacy saveFileToDrive (Base64). Consider using getUploadUrl.");
const config = new Config()
const driveService = new GASDriveService()
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, filename)
driveService.saveFile(blob, folder.getId())
}
// Picker Callback specific handler if needed, or we just rely on frontend passing back file ID
// Implementing a "copy from Picker" handler
// Implementing a "copy from Picker" handler
export function importFromPicker(sku: string, fileId: string, mimeType: string, name: string, imageUrl: string | null) {
const driveService = new GASDriveService()
let config;
try {
config = new Config();
} catch(e) {
console.error("Config init failed in importFromPicker", e);
throw new Error("Configuration Error: " + e.message);
}
console.log(`importFromPicker starting for SKU: ${sku}`);
// STEP 1: Acquire/Create File in Root (Safe Zone)
let finalFile: GoogleAppsScript.Drive.File;
try {
if (fileId && !imageUrl) {
// Case A: Existing Drive File (Copy it)
// Note: makeCopy(name) w/o folder argument copies to the same parent as original usually, or root?
// Actually explicitly copying to Root is safer for "new" file.
const source = DriveApp.getFileById(fileId);
finalFile = source.makeCopy(name); // Default location
console.log(`Step 1 Success: Drive File copied to Root/Default. ID: ${finalFile.getId()}`);
} else if (imageUrl) {
console.log(`[importFromPicker] Input: Mime=${mimeType}, Name=${name}, URL=${imageUrl}`);
// Case B: URL (Photos) -> Blob -> File
// Handling high-res parameter
if (imageUrl.includes("googleusercontent.com")) {
if (mimeType && mimeType.startsWith("video/")) {
// For videos, =dv retrieves the actual video file (download video)
if (!imageUrl.includes("=dv")) {
imageUrl += "=dv";
}
} else {
// For images, =d retrieves the full download
if (!imageUrl.includes("=d")) {
imageUrl += "=d";
}
}
}
console.log(`[importFromPicker] Fetching URL: ${imageUrl}`);
const response = UrlFetchApp.fetch(imageUrl, {
headers: {
Authorization: `Bearer ${ScriptApp.getOAuthToken()}`
},
muteHttpExceptions: true
});
console.log(`Download Response Code: ${response.getResponseCode()}`);
if (response.getResponseCode() !== 200) {
const errorBody = response.getContentText().substring(0, 500);
throw new Error(`Request failed for ${imageUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
}
const blob = response.getBlob();
console.log(`Blob Content-Type: ${blob.getContentType()}`);
let fileName = name || `photo_${Date.now()}.jpg`;
// Fix Filename Extension if MimeType mismatch
// (e.g. we downloaded a video, but filename is .jpg)
if (blob.getContentType().startsWith('video/') && fileName.match(/\.jpg|\.png|\.jpeg$/i)) {
console.log(`[importFromPicker] Filename extension correction needed for video. Old: ${fileName}`);
fileName = fileName.replace(/\.[^/.]+$/, "") + ".mp4";
console.log(`[importFromPicker] New Filename: ${fileName}`);
}
blob.setName(fileName);
try {
// Sanitize blob to remove any hidden metadata causing DriveApp issues
const cleanBlob = Utilities.newBlob(blob.getBytes(), blob.getContentType(), fileName);
finalFile = DriveApp.createFile(cleanBlob); // Creates in Root
console.log(`Step 1 Success: File created in Root. ID: ${finalFile.getId()}, Mime: ${finalFile.getMimeType()}`);
} catch (createErr) {
console.warn("DriveApp.createFile failed with clean blob. Trying Advanced Drive API...", createErr);
try {
// Fallback to Advanced Drive Service (v3 usually, or v2)
// Note: v2 uses 'insert' & 'title', v3 uses 'create' & 'name'
// We try v3 first as it's the modern default.
if (typeof Drive === 'undefined') {
throw new Error("Advanced Drive Service is not enabled. Please enable 'Drive API' in Apps Script Services.");
}
const drive = Drive as any;
let insertedFile;
if (drive.Files.create) {
// v3
const fileResource = { name: fileName, mimeType: blob.getContentType() };
insertedFile = drive.Files.create(fileResource, blob);
} else if (drive.Files.insert) {
// v2 fallback
const fileResource = { title: fileName, mimeType: blob.getContentType() };
insertedFile = drive.Files.insert(fileResource, blob);
} else {
throw new Error("Unknown Drive API version (neither create nor insert found).");
}
finalFile = DriveApp.getFileById(insertedFile.id);
console.log(`Step 1 Success (Advanced API): Photo downloaded to Root. ID: ${finalFile.getId()}`);
} catch (advErr) {
const metadata = `Type: ${blob.getContentType()}, Size: ${blob.getBytes().length}`;
console.error(`All file creation methods failed. Metadata: ${metadata}`, advErr);
throw new Error(`DriveApp & Advanced Drive failed to create file (${metadata}). Error: ${advErr.message}`);
}
}
} else {
throw new Error("No File ID and No Image URL provided.");
}
} catch (e) {
console.error("Step 1 Failed (File Creation)", e);
throw e; // Re-throw modified error
}
// STEP 2: Get Target Folder
let folder: GoogleAppsScript.Drive.Folder;
try {
folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId);
console.log(`Step 2 Success: Target folder found/created. Name: ${folder.getName()}`);
} catch (e) {
console.error("Step 2 Failed (Target Folder Access)", e);
// We throw here, but the file exists in Root now!
throw new Error(`File saved to Drive Root, but failed to put in SKU folder: ${e.message}`);
}
// STEP 3: Move File to Folder
try {
finalFile.moveTo(folder);
console.log(`Step 3 Success: File moved to target folder.`);
} catch (e) {
console.error("Step 3 Failed (Move)", e);
throw new Error(`File created (ID: ${finalFile.getId()}), but failed to move to folder: ${e.message}`);
}
}
export function debugScopes() {
const token = ScriptApp.getOAuthToken();
console.log("Current Token exists: " + (token ? "YES" : "NO"));
// We can't see exact scopes easily from server side without a library,
// but we can check if the specific Photos pickup works?
// No, let's just confirm the code is running the latest version.
}
export function debugFolderAccess() {
const config = new Config()
const ui = SpreadsheetApp.getUi();
if (!config.productPhotosFolderId) {
ui.alert("Config Error", "No productPhotosFolderId found in vars.", ui.ButtonSet.OK);
return;
}
const id = config.productPhotosFolderId.trim();
const info = [`Configured ID: '${id}'`];
try {
info.push(`User: ${Session.getActiveUser().getEmail()}`);
info.push(`Effective: ${Session.getEffectiveUser().getEmail()}`);
const folder = DriveApp.getFolderById(id);
info.push(`Success! Found Folder: ${folder.getName()}`);
info.push(`URL: ${folder.getUrl()}`);
info.push("Access seems OK from Menu context.");
} catch (e) {
info.push("FAILED to access as FOLDER.");
info.push(`Error: ${e.message}`);
// Try as file
try {
const file = DriveApp.getFileById(id);
info.push(`\nWAIT! This ID belongs to a FILE, not a FOLDER!`);
info.push(`File Name: ${file.getName()}`);
info.push(`Mime: ${file.getMimeType()}`);
} catch (e2) {
info.push(`\nNot a File either: ${e2.message}`);
}
// Try Advanced Drive API
try {
const drive = (typeof Drive !== 'undefined') ? (Drive as any) : undefined;
if (!drive) {
info.push("\nAdvanced Drive Service (Drive) is NOT enabled. Please enable it in 'Services' > 'Drive API'.");
} else {
const advItem = drive.Files.get(id, { supportsAllDrives: true });
info.push(`\nSuccess via Advanced Drive API!`);
info.push(`Title: ${advItem.title}`);
info.push(`Mime: ${advItem.mimeType}`);
info.push(`Note: If this works but DriveApp fails, this is likely a Shared Drive or permissions issue.`);
}
} catch (e3) {
info.push(`\nAdvanced Drive API Failed: ${e3.message}`);
}
}
ui.alert("Folder Access Debug", info.join("\n\n"), ui.ButtonSet.OK);
}
export function createPhotoSession() {
const url = 'https://photospicker.googleapis.com/v1/sessions';
const token = ScriptApp.getOAuthToken();
const options = {
method: 'post' as const,
contentType: 'application/json',
headers: {
Authorization: `Bearer ${token}`
},
payload: JSON.stringify({}) // Default session
};
try {
const response = UrlFetchApp.fetch(url, options);
const data = JSON.parse(response.getContentText());
return data; // { id: "...", pickerUri: "..." }
} catch (e) {
console.error("Failed to create photo session", e);
throw new Error("Failed to create photo session: " + e.message);
}
}
export function checkPhotoSession(sessionId: string) {
// Use pageSize=100 or check documentation. Default is usually fine.
// We need to poll until we get mediaItems.
const url = `https://photospicker.googleapis.com/v1/mediaItems?sessionId=${sessionId}&pageSize=10`;
const token = ScriptApp.getOAuthToken();
const options = {
method: 'get' as const,
headers: {
Authorization: `Bearer ${token}`
},
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(url, options);
const text = response.getContentText();
console.log(`Polling session ${sessionId}: ${response.getResponseCode()}`);
if (response.getResponseCode() !== 200) {
// 400 Bad Request often means "Picker session not ready" or "Empty" if using the wrong check.
// But documentation says FAILED_PRECONDITION (400?) if user hasn't finished.
console.log("Polling response: " + response.getResponseCode() + " " + text);
return { status: 'waiting' }; // Treat as waiting
}
const data = JSON.parse(text);
// data.mediaItems might be undefined if nothing picked yet?
// Or API waits? Actually checking documentation: it returns empty list or hangs?
// It usually returns immediatley. If empty, user hasn't picked.
if (data.mediaItems && data.mediaItems.length > 0) {
return { status: 'complete', mediaItems: data.mediaItems };
}
return { status: 'waiting' };
} catch (e) {
console.error("Failed to check photo session", e);
return { status: 'error', message: e.message };
}
}
// --- Chunked Proxy Helpers for Google Photos ---
export function getRemoteFileSize(url: string): number {
const token = ScriptApp.getOAuthToken();
const params = {
method: 'get' as const,
headers: {
Authorization: `Bearer ${token}`,
Range: 'bytes=0-0'
},
muteHttpExceptions: true
};
let response = UrlFetchApp.fetch(url, params);
if (response.getResponseCode() >= 400) {
throw new Error(`Failed to get file size: ${response.getResponseCode()} ${response.getContentText()}`);
}
const headers = response.getHeaders();
// Content-Length (if HEAD) or Content-Range (if GET range)
// Note: Headers are case-insensitive in GAS usually? But let's check safely.
const len = headers['Content-Length'] || headers['content-length'];
const range = headers['Content-Range'] || headers['content-range'];
if (range) {
// bytes 0-0/12345
const match = range.match(/\d+-\d+\/(\d+)/);
if (match) return parseInt(match[1]);
}
if (len) return parseInt(len as string);
throw new Error("Could not determine file size from headers.");
}
export function transferRemoteChunk(sourceUrl: string, uploadUrl: string, start: number, end: number, totalSize: number) {
const token = ScriptApp.getOAuthToken();
// 1. Fetch from Source (Google Photos)
const getParams = {
method: 'get' as const,
headers: {
Authorization: `Bearer ${token}`,
Range: `bytes=${start}-${end}`
},
muteHttpExceptions: true
};
const sourceResponse = UrlFetchApp.fetch(sourceUrl, getParams);
if (sourceResponse.getResponseCode() !== 200 && sourceResponse.getResponseCode() !== 206) {
throw new Error(`Source fetch failed: ${sourceResponse.getResponseCode()} ${sourceResponse.getContentText()}`);
}
// 2. Prepare Payload
// Use getContent() to get raw bytes. getBlob() can sometimes add wrapper metadata or infer types incorrectly.
let bytes = sourceResponse.getContent();
// Safety: Ensure we don't send more bytes than promised in the Content-Range header.
// sometimes Range requests return more/different if server is quirky.
const expectedSize = end - start + 1;
if (bytes.length > expectedSize) {
console.warn(`[transferRemoteChunk] Trimming bytes. Requested ${expectedSize}, got ${bytes.length}.`);
bytes = bytes.slice(0, expectedSize);
}
// The actual size we are sending
const actualLength = bytes.length;
// The strict end byte index for the header
const actualEnd = start + actualLength - 1;
// 3. Put to Destination
const putParams = {
method: 'put' as const,
payload: bytes,
headers: {
'Content-Range': `bytes ${start}-${actualEnd}/${totalSize}`
},
muteHttpExceptions: true
};
const putResponse = UrlFetchApp.fetch(uploadUrl, putParams);
const code = putResponse.getResponseCode();
if (code !== 308 && code !== 200 && code !== 201) {
throw new Error(`Upload PUT failed: ${code} ${putResponse.getContentText()}`);
}
// Return bytesUploaded so client can adjust if we were forced to send fewer bytes
return { success: true, code: code, bytesUploaded: actualLength };
}