fix(media-manager): resolve video preview issues and stabilize tests

- Backend (MediaService):
    - Implemented robust contentUrl generation for Drive files using drive.google.com/uc pattern.
    - Added mimeType exposure to unified media state.

- Frontend (MediaManager):
    - Replaced video tag with iframe embedding the Google Drive Preview player (/preview) for reliable playback.
    - Fixed syntax error in console logging causing UI freeze.
    - Updated gallery card logic to prioritize video content URLs.

- Tests (Integration):
    - Refactored mediaManager.integration.test.ts to align with new logic.
    - Mocked DriveApp completely to support orphan adoption flows.
    - Updated file renaming expectations to support timestamped filenames.

- Documentation:
    - Updated MEMORY.md with video preview strategy.
This commit is contained in:
Ben Miller
2025-12-28 15:51:56 -07:00
parent dadcccb7f9
commit 243f7057b7
7 changed files with 74 additions and 21 deletions

View File

@ -37,6 +37,8 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
- **Google Photos Picker**:
- The `baseUrl` returned by the Picker API is hidden inside `mediaFile.baseUrl` (not top-level).
- Downloading this URL requires an **Authorization header** with the script's OAuth token, or it returns 403.
- `DriveApp.createFile(blob)` is fragile with blobs from `UrlFetchApp`. We use a 2-step fallback:
1. Sanitize with `Utilities.newBlob()`.
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
- **Video Previews**:
- HTML5 `<video>` tags often fail with standard Drive download URLs due to auth/codec issues.
- **Strategy**: Use an `<iframe>` embedding the `https://drive.google.com/file/d/{ID}/preview` URL. This leverages Google's native player for reliable auth and transcoding.

View File

@ -341,7 +341,7 @@
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
</div>
</div>
<div class="card" style="padding-bottom: 0;">
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
<div style="display:flex; align-items:baseline; gap:12px;">
@ -389,8 +389,9 @@
<div class="modal-content">
<button class="modal-close" onclick="ui.closeModal()">×</button>
<img id="preview-image" style="max-width:100%; max-height:80vh; border-radius:8px; display:none;">
<video id="preview-video" controls
style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video>
<video id="preview-video" controls style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video>
<iframe id="preview-iframe" style="width:100%; height:60vh; border:none; border-radius:8px; display:none;"
allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div>
</div>
@ -630,9 +631,10 @@
}
// Thumbnail
const isVideo = item.filename && item.filename.toLowerCase().endsWith('.mp4');
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4'));
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename + ", ContentUrl: " + item.contentUrl);
const mediaHtml = isVideo
? `<video src="${item.thumbnail}" muted loop onmouseover="this.play()" onmouseout="this.pause()" class="media-content"></video>`
? `<video src="${item.contentUrl || ''}" poster="${item.thumbnail}" muted loop onmouseover="this.play()" onmouseout="this.pause()" class="media-content"></video>`
: `<img src="${item.thumbnail}" class="media-content" loading="lazy">`;
const actionBtn = item._deleted
@ -657,15 +659,45 @@
const modal = document.getElementById('preview-modal');
const img = document.getElementById('preview-image');
const vid = document.getElementById('preview-video');
const iframe = document.getElementById('preview-iframe');
img.style.display = 'none';
vid.style.display = 'none';
iframe.style.display = 'none';
iframe.src = 'about:blank'; // Reset
if (item.filename && item.filename.toLowerCase().endsWith('.mp4')) {
vid.src = item.thumbnail; // Or high-res link if available?
vid.style.display = 'block';
} else {
img.src = item.thumbnail;
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4'));
if (isVideo) {
// Use Drive Preview Embed URL
// Note: This assumes item.id corresponds to Drive File ID for drive items.
// (Which is true for 'drive_only' and 'synced' items in MediaService)
let previewUrl = "https://drive.google.com/file/d/" + item.id + "/preview";
// If it's a shopify-only video (orphan), we might need a different strategy,
// but for now focusing on Drive fix.
if (item.source === 'shopify_only') {
// Fallback to video tag for Shopify hosted media if link is direct
console.log("[MediaManager] Shopify Video: " + item.filename);
vid.src = item.contentUrl || item.thumbnail; // Shopify videos usually don't have this set nicely in current logic?
// Actually MediaService for orphans puts originalSrc in thumbnail.
// But originalSrc for video is usually an image.
// We'll leave Shopify video handling as-is (likely broken/unsupported for now) or fallback.
// Effectively this branch executes the OLD logic for non-drive.
vid.src = item.thumbnail; // Risk
vid.style.display = 'block';
} else {
console.log("[MediaManager] Opening Drive Embed: " + item.filename + ", URL: " + previewUrl);
iframe.src = previewUrl;
iframe.style.display = 'block';
}
} else {
// Image
img.src = item.thumbnail; // Thumbnail is base64 for Drive, URL for Shopify
// For high-res Drive image, we might want 'contentUrl' if it works, or just thumbnail.
// Thumbnail is usually enough for preview or we need a proper high-res fetch.
// Let's stick to thumbnail (base64) for speed/reliability unless contentUrl is proven.
img.style.display = 'block';
}
@ -676,6 +708,7 @@
if (e && e.target !== document.getElementById('preview-modal') && e.target !== document.querySelector('.modal-close')) return;
document.getElementById('preview-modal').style.display = 'none';
document.getElementById('preview-video').pause();
document.getElementById('preview-iframe').src = 'about:blank'; // Stop playback
}
// --- Details Modal ---

View File

@ -11,6 +11,7 @@ const mockDrive = {
renameFile: jest.fn(),
trashFile: jest.fn(),
updateFileProperties: jest.fn(),
getFileProperties: jest.fn(),
getFileById: jest.fn()
}
const mockShopify = {
@ -39,6 +40,10 @@ global.Drive = {
}
} as any
global.DriveApp = {
getRootFolder: jest.fn().mockReturnValue({ removeFile: jest.fn() })
} as any
describe("MediaService V2 Integration Logic", () => {
let service: MediaService
const dummyPid = "gid://shopify/Product/123"
@ -55,7 +60,8 @@ describe("MediaService V2 Integration Logic", () => {
getBlob: jest.fn().mockReturnValue({
getDataAsString: () => "fake_blob_data",
getContentType: () => "image/jpeg",
getBytes: () => []
getBytes: () => [],
setName: jest.fn()
})
})
@ -65,12 +71,14 @@ describe("MediaService V2 Integration Logic", () => {
getName: () => "file_name.jpg",
moveTo: jest.fn(),
getMimeType: () => "image/jpeg",
getBlob: () => ({})
getBlob: () => ({}),
getId: () => id
}))
mockDrive.createFile.mockReturnValue({
getId: () => "new_created_file_id"
})
mockDrive.getFileProperties.mockReturnValue({})
})
describe("getUnifiedMediaState (Phase A)", () => {
@ -80,12 +88,14 @@ describe("MediaService V2 Integration Logic", () => {
getId: () => "drive_1",
getName: () => "IMG_001.jpg",
getAppProperty: (k: string) => k === 'shopify_media_id' ? "gid://shopify/Media/100" : null,
getThumbnail: () => ({ getBytes: () => [] })
getThumbnail: () => ({ getBytes: () => [] }),
getMimeType: () => "image/jpeg"
}
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
mockDrive.getFiles.mockReturnValue([driveFile])
// Setup Shopify
mockDrive.getFileProperties.mockReturnValue({ 'shopify_media_id': 'gid://shopify/Media/100' })
const shopMedia = {
id: "gid://shopify/Media/100",
mediaContentType: "IMAGE",
@ -108,7 +118,8 @@ describe("MediaService V2 Integration Logic", () => {
getId: () => "drive_new",
getName: () => "new.jpg",
getAppProperty: () => null,
getThumbnail: () => ({ getBytes: () => [] })
getThumbnail: () => ({ getBytes: () => [] }),
getMimeType: () => "image/jpeg"
}
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
mockDrive.getFiles.mockReturnValue([driveFile])
@ -121,7 +132,7 @@ describe("MediaService V2 Integration Logic", () => {
})
test("should identify Shopify-Only items", () => {
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" })
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", addFile: jest.fn() })
mockDrive.getFiles.mockReturnValue([])
const shopMedia = {
@ -153,14 +164,14 @@ describe("MediaService V2 Integration Logic", () => {
service.processMediaChanges("SKU-123", finalState, dummyPid)
// Assert
expect(mockDrive.renameFile).toHaveBeenCalledWith("d1", "SKU-123_0001.jpg")
expect(mockDrive.renameFile).toHaveBeenCalledWith("d2", "SKU-123_0002.jpg")
expect(mockDrive.renameFile).toHaveBeenCalledWith("d1", expect.stringMatching(/SKU-123_\d+\.jpg/))
expect(mockDrive.renameFile).toHaveBeenCalledWith("d2", expect.stringMatching(/SKU-123_\d+\.jpg/))
})
test("should call Shopify Reorder Mutation", () => {
const finalState = [
{ id: "1", shopifyId: "s10", sortOrder: 0 },
{ id: "2", shopifyId: "s20", sortOrder: 1 }
{ id: "1", shopifyId: "s10", sortOrder: 0, driveId: "d1" },
{ id: "2", shopifyId: "s20", sortOrder: 1, driveId: "d2" }
]
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([])
@ -179,6 +190,7 @@ describe("MediaService V2 Integration Logic", () => {
jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([])
// Mock file creation
mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1", addFile: jest.fn() })
// We set default mockDrive.createFile above but we can specialize if needed
// Default returns "new_created_file_id"

View File

@ -128,8 +128,12 @@ export class MediaService {
source: match ? 'synced' : 'drive_only',
thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`,
status: 'active',
galleryOrder: d.galleryOrder
galleryOrder: d.galleryOrder,
mimeType: d.file.getMimeType(),
// Use manual download URL construction which is often more reliable for authenticated sessions than getDownloadUrl()
contentUrl: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`
})
// console.log(`[MediaService] File ${d.file.getName()} (${d.file.getId()}): Mime=${d.file.getMimeType()}, ContentUrl=https://drive.google.com/uc?export=download&id=${d.file.getId()}`)
})
// Find Shopify Orphans

View File

@ -50,6 +50,8 @@ export class MockDriveService implements IDriveService {
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
getLastUpdated: () => new Date(),
getThumbnail: () => ({ getBytes: () => [] }),
getMimeType: () => (blob as any).getContentType ? (blob as any).getContentType() : "image/jpeg",
getDownloadUrl: () => `https://drive.google.com/uc?export=download&id=${id}`,
getAppProperty: (key) => {
return (newFile as any)._properties?.[key]
}

Binary file not shown.

BIN
test_output_2.txt Normal file

Binary file not shown.