From 7c096b7de05713ebb25c4b4040bea8edf05d4f63 Mon Sep 17 00:00:00 2001 From: Jeremy Zongker Date: Tue, 18 Aug 2026 06:39:16 +0000 Subject: [PATCH] List Drive audio and extension-matched media in the plans picker. --- .changeset/googledrive-audio-listing.md | 5 +++++ content-providers/src/interfaces.ts | 4 ++-- .../providers/googledrive/GoogleDriveProvider.ts | 7 +++++-- .../lessonsChurch/LessonsChurchConverters.ts | 2 +- content-providers/src/utils.ts | 12 ++++++++---- .../tests/googleDriveProvider.test.ts | 15 ++++++++++++--- 6 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 .changeset/googledrive-audio-listing.md diff --git a/.changeset/googledrive-audio-listing.md b/.changeset/googledrive-audio-listing.md new file mode 100644 index 0000000..7487e54 --- /dev/null +++ b/.changeset/googledrive-audio-listing.md @@ -0,0 +1,5 @@ +--- +"@churchapps/content-providers": patch +--- + +List Google Drive audio files and extension-matched media (e.g. octet-stream .mp4 uploads) when browsing plans content, and add "audio" as a media type (ChurchAppsSupport #944). diff --git a/content-providers/src/interfaces.ts b/content-providers/src/interfaces.ts index cc24d68..f9cc33e 100644 --- a/content-providers/src/interfaces.ts +++ b/content-providers/src/interfaces.ts @@ -74,7 +74,7 @@ export interface ContentFile { type: "file"; id: string; title: string; - mediaType: "video" | "image"; + mediaType: "video" | "image" | "audio"; thumbnail?: string; url: string; downloadUrl?: string; @@ -172,7 +172,7 @@ export interface InstructionItem { children?: InstructionItem[]; downloadUrl?: string; thumbnail?: string; - mediaType?: "video" | "image"; + mediaType?: "video" | "image" | "audio"; } export interface Instructions { diff --git a/content-providers/src/providers/googledrive/GoogleDriveProvider.ts b/content-providers/src/providers/googledrive/GoogleDriveProvider.ts index da546bf..077c0dd 100644 --- a/content-providers/src/providers/googledrive/GoogleDriveProvider.ts +++ b/content-providers/src/providers/googledrive/GoogleDriveProvider.ts @@ -5,7 +5,7 @@ import { } from "../../interfaces"; import { OAuthHelper } from "../../helpers"; import { getProviderSecret } from "../../helpers/ProviderSecrets"; -import { detectMediaType, filesToInstructions } from "../../utils"; +import { detectMediaType, filesToInstructions, isMediaFile } from "../../utils"; import { BaseProvider } from "../BaseProvider"; import { DriveFile, DriveFileListResponse } from "./GoogleDriveInterfaces"; @@ -82,7 +82,10 @@ export class GoogleDriveProvider extends BaseProvider { private async childItems(folderId: string, auth?: ContentProviderAuthData | null): Promise<{ folders: DriveFile[]; mediaFiles: DriveFile[] }> { const entries = await this.listFiles(`'${folderId}' in parents and trashed=false`, auth); const folders = entries.filter(e => e.mimeType === FOLDER_MIME); - const mediaFiles = entries.filter(e => e.mimeType.startsWith("video/") || e.mimeType.startsWith("image/")); + // Drive reports uploads like .mp3/.mp4 as application/octet-stream at times; fall back to the filename. Google-apps docs (Docs/Sheets/Slides) are never playable. + const mediaFiles = entries.filter(e => e.mimeType !== FOLDER_MIME + && !e.mimeType.startsWith("application/vnd.google-apps") + && (["video/", "image/", "audio/"].some(prefix => e.mimeType.startsWith(prefix)) || isMediaFile(e.name))); return { folders, mediaFiles }; } diff --git a/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts b/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts index 5443305..52fc262 100644 --- a/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts +++ b/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts @@ -81,7 +81,7 @@ export function buildSectionActionsMap(actionsResponse: VenueActionsResponseInte const actionThumbnailMap = new Map(); const actionUrlMap = new Map(); const actionContentMap = new Map(); - const actionMediaTypeMap = new Map(); + const actionMediaTypeMap = new Map(); if (feedResponse?.sections) { for (const section of feedResponse.sections) { for (const action of section.actions || []) { diff --git a/content-providers/src/utils.ts b/content-providers/src/utils.ts index 6401c7f..b6cd170 100644 --- a/content-providers/src/utils.ts +++ b/content-providers/src/utils.ts @@ -13,25 +13,29 @@ const VIDEO_EXTENSIONS = [".mp4", ".webm", ".m3u8", ".mov", ".avi", ".mkv", ".m4 const IMAGE_EXTENSIONS = [ ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp", ".tiff", ".tif" ]; +// Matches the formats the serving player already handles +const AUDIO_EXTENSIONS = [".mp3", ".m4a", ".aac", ".wav", ".flac", ".oga"]; -export function detectMediaType(url: string, explicitType?: string): "video" | "image" { +export function detectMediaType(url: string, explicitType?: string): "video" | "image" | "audio" { if (explicitType === "video" || explicitType?.startsWith("video/")) return "video"; if (explicitType === "image" || explicitType?.startsWith("image/")) return "image"; + if (explicitType === "audio" || explicitType?.startsWith("audio/")) return "audio"; const lower = url.toLowerCase(); if (VIDEO_EXTENSIONS.some(p => lower.includes(p)) || lower.includes("stream.mux.com")) return "video"; + if (AUDIO_EXTENSIONS.some(p => lower.includes(p))) return "audio"; return "image"; } export function isMediaFile(filename: string): boolean { const lower = filename.toLowerCase(); - return [...VIDEO_EXTENSIONS, ...IMAGE_EXTENSIONS].some(ext => lower.endsWith(ext)); + return [...VIDEO_EXTENSIONS, ...IMAGE_EXTENSIONS, ...AUDIO_EXTENSIONS].some(ext => lower.endsWith(ext)); } export function createFolder(id: string, title: string, path: string, thumbnail?: string, isLeaf?: boolean): ContentFolder { return { type: "folder", id, title, path, thumbnail, isLeaf }; } -export function createFile(id: string, title: string, url: string, options?: { mediaType?: "video" | "image"; thumbnail?: string; muxPlaybackId?: string; seconds?: number; loop?: boolean; loopVideo?: boolean; streamUrl?: string; }): ContentFile { +export function createFile(id: string, title: string, url: string, options?: { mediaType?: "video" | "image" | "audio"; thumbnail?: string; muxPlaybackId?: string; seconds?: number; loop?: boolean; loopVideo?: boolean; streamUrl?: string; }): ContentFile { return { type: "file", id, title, url, mediaType: options?.mediaType ?? detectMediaType(url), thumbnail: options?.thumbnail, muxPlaybackId: options?.muxPlaybackId, seconds: options?.seconds, loop: options?.loop, loopVideo: options?.loopVideo, streamUrl: options?.streamUrl }; } @@ -71,7 +75,7 @@ export function filesToInstructions(name: string, files: ContentFile[], section? } /** Declared type first; else sniff the label (usually the original filename) alongside the URL — extension-less URLs (Dropbox temp links) defeat URL sniffing alone. */ -export function instructionItemMediaType(item: InstructionItem): "video" | "image" { +export function instructionItemMediaType(item: InstructionItem): "video" | "image" | "audio" { return detectMediaType(`${item.downloadUrl || ""} ${item.label || ""}`, item.mediaType); } diff --git a/content-providers/tests/googleDriveProvider.test.ts b/content-providers/tests/googleDriveProvider.test.ts index 182a011..6b1f364 100644 --- a/content-providers/tests/googleDriveProvider.test.ts +++ b/content-providers/tests/googleDriveProvider.test.ts @@ -12,7 +12,9 @@ const listing = { { id: "folderB", name: "Week 10", mimeType: "application/vnd.google-apps.folder" }, { id: "vid1", name: "Opener.mp4", mimeType: "video/mp4", thumbnailLink: "https://x/t.jpg", webContentLink: "https://drive.example/dl?id=vid1" }, { id: "img1", name: "Slide", mimeType: "image/png" }, - { id: "doc1", name: "Notes", mimeType: "application/vnd.google-apps.document" } + { id: "aud1", name: "Worship Set.mp3", mimeType: "audio/mpeg" }, + { id: "octet1", name: "Recap.mp4", mimeType: "application/octet-stream" }, + { id: "pdf1", name: "Notes.pdf", mimeType: "application/pdf" } ] }; const subfolderListing = { files: [{ id: "sub1", name: "Inner", mimeType: "application/vnd.google-apps.folder", parents: ["folderA"] }] }; @@ -36,7 +38,7 @@ test("browse maps folders (with leaf detection) and media files, dropping non-me const items = await new GoogleDriveProvider().browse("/parent1", auth); assert.equal(new URL(requests[0]).searchParams.get("q"), "'parent1' in parents and trashed=false"); - assert.deepEqual(items.map(i => [i.type, i.id]), [["folder", "folderA"], ["folder", "folderB"], ["file", "vid1"], ["file", "img1"]]); + assert.deepEqual(items.map(i => [i.type, i.id]), [["folder", "folderA"], ["folder", "folderB"], ["file", "vid1"], ["file", "img1"], ["file", "aud1"], ["file", "octet1"]]); const [folderA, folderB] = items as any[]; assert.equal(folderA.path, "/folderA"); @@ -51,6 +53,13 @@ test("browse maps folders (with leaf detection) and media files, dropping non-me const image = items[3] as any; assert.equal(image.mediaType, "image"); assert.equal(image.url, "https://drive.google.com/uc?id=img1&export=download"); + + const audio = items[4] as any; + assert.equal(audio.mediaType, "audio"); + + // Extension rescues files Drive reports as octet-stream; the .mp4 name types it as video + const octet = items[5] as any; + assert.equal(octet.mediaType, "video"); } finally { restore(); } @@ -77,7 +86,7 @@ test("getPlaylist returns media files for a folder and null at root or when empt try { const provider = new GoogleDriveProvider(); const files = await provider.getPlaylist("/parent1", auth); - assert.deepEqual(files?.map(f => f.id), ["vid1", "img1"]); + assert.deepEqual(files?.map(f => f.id), ["vid1", "img1", "aud1", "octet1"]); assert.equal(await provider.getPlaylist("/", auth), null); } finally { restore();