Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signpresenter-playlist-media.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@churchapps/content-providers": patch
---

Fix SignPresenter playlists showing no media: playlist contents now come from the documented `/v2/playlists/:id` player endpoint, mapping image, video, stream, and media-file web slides to playable files (with message thumbnail, slide seconds, and loop), instead of `/content/playlists/:id/messages`, which only covers templates 1 and 3 and usually returned empty urls. The content-messages endpoint remains as a fallback, now also accepting a message's thumbnail or image when the url is empty.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ContentProviderConfig, ContentProviderAuthData, ContentItem, ContentFile, ProviderLogos, ProviderCapabilities, AuthType, Instructions, DeviceAuthorizationResponse, DeviceFlowPollResult } from "../../interfaces";
import { detectMediaType, createFile, filesToInstructions } from "../../utils";
import { detectMediaType, isMediaFile, createFile, filesToInstructions } from "../../utils";
import { parsePath } from "../../pathUtils";
import { OAuthHelper, DeviceFlowHelper } from "../../helpers";
import { BaseProvider } from "../BaseProvider";
Expand Down Expand Up @@ -55,15 +55,52 @@ export class SignPresenterProvider extends BaseProvider {
}));
}

/** image and video/stream slides play directly; web slides only when the url is itself a media file. html/pdf/multi_zone and generic web pages aren't playable in ChurchApps. */
private slideMediaType(slideType: string | undefined, url: string): "video" | "image" | "audio" | null {
if (slideType === "image") return "image";
if (slideType === "video" || slideType === "stream") return "video";
if (slideType === "web" && isMediaFile(url)) return detectMediaType(url);
return null;
}

/** The documented player contract; /content/playlists/:id/messages only covers templates 1 and 3 so most playlists come back empty there. */
private async getMessages(playlistId: string, auth?: ContentProviderAuthData | null): Promise<ContentFile[]> {
const response = await this.apiRequest<unknown>(`/v2/playlists/${playlistId}`, auth);
const messages = this.extractList(response, "messages");
if (messages.length === 0) return this.getLegacyMessages(playlistId, auth);

const files: ContentFile[] = [];
messages.forEach((msg, messageIndex) => {
const slides = Array.isArray(msg.slides) ? (msg.slides as Record<string, unknown>[]) : [];
slides.forEach((slide, slideIndex) => {
const data = (slide.data || {}) as Record<string, unknown>;
const url = typeof data.url === "string" ? data.url.trim() : "";
const mediaType = url ? this.slideMediaType(slide.type as string | undefined, url) : null;
if (!mediaType) return;

const file = createFile(`${playlistId}-${messageIndex}-${slideIndex}`, msg.name as string, url, {
mediaType,
thumbnail: msg.thumbnail as string | undefined,
seconds: slide.seconds as number | undefined,
loop: data.loop as boolean | undefined
});
file.downloadUrl = url;
files.push(file);
});
});

return files;
}

private async getLegacyMessages(playlistId: string, auth?: ContentProviderAuthData | null): Promise<ContentFile[]> {
const response = await this.apiRequest<unknown>(`/content/playlists/${playlistId}/messages`, auth);
if (!response) return [];

const files: ContentFile[] = [];
for (const msg of this.extractList(response, "messages")) {
if (!msg.url) continue;
const url = [msg.url, msg.thumbnail, msg.image].map(v => (typeof v === "string" ? v.trim() : "")).find(Boolean) || "";
if (!url) continue;

const url = msg.url as string;
const file = createFile(msg.id as string, msg.name as string, url, {
mediaType: detectMediaType(url, msg.mediaType as string | undefined),
thumbnail: (msg.thumbnail || msg.image) as string | undefined,
Expand Down
182 changes: 182 additions & 0 deletions content-providers/tests/signPresenterProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { SignPresenterProvider } from "../src/providers/signPresenter";
import { ContentProviderAuthData } from "../src/interfaces";

const auth: ContentProviderAuthData = { access_token: "at", refresh_token: "rt", token_type: "Bearer", created_at: 0, expires_in: 3600, scope: "" };

const playlists = [
{ id: "99", name: "Announcements", image: "https://x/announcements.jpg" },
{ id: "100", name: "Lobby Loop" }
];

const v2Playlist = {
id: "99",
name: "Announcements",
messages: [
{ name: "Welcome", thumbnail: "https://x/welcome-thumb.jpg", slides: [{ seconds: 10, type: "image", data: { url: "https://x/welcome.png" } }] },
{ name: "Sermon Clip", thumbnail: "https://x/clip-thumb.jpg", slides: [{ seconds: 30, type: "video", data: { url: "https://x/clip.mp4", loop: true } }] },
{ name: "Live Feed", slides: [{ seconds: 60, type: "stream", data: { url: "https://stream.mux.com/abc123.m3u8" } }] },
{ name: "Countdown", slides: [{ seconds: 5, type: "web", data: { url: "https://x/countdown.gif" } }] },
{ name: "Website", slides: [{ seconds: 5, type: "web", data: { url: "https://example.com/page" } }] },
{ name: "Bulletin", slides: [{ seconds: 5, type: "pdf", data: { url: "https://x/bulletin.pdf" } }] },
{ name: "Custom Html", slides: [{ seconds: 5, type: "html", data: { url: "https://x/custom.html" } }] },
{ name: "Zones", slides: [{ seconds: 5, type: "multi_zone", data: {} }] },
{
name: "Gallery",
thumbnail: "https://x/gallery-thumb.jpg",
slides: [
{ seconds: 8, type: "image", data: { url: "https://x/gallery1.jpg" } },
{ seconds: 8, type: "image", data: { url: "https://x/gallery2.jpg" } }
]
}
]
};

const legacyMessages = {
messages: [
{ id: "m1", name: "Has Url", url: "https://x/a.mp4", seconds: 12 },
{ id: "m2", name: "Thumb Only", url: "", thumbnail: " https://x/thumb.jpg " },
{ id: "m3", name: "Image Only", image: "https://x/image.png" },
{ id: "m4", name: "Empty", url: " " }
]
};

function mockFetch(handler: (url: string) => unknown) {
const realFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const body = handler(String(url));
if (body === null) return { ok: false, status: 404, statusText: "Not Found", text: async () => "" } as Response;
return { ok: true, json: async () => body } as Response;
}) as typeof fetch;
return () => { globalThis.fetch = realFetch; };
}

test("browse root returns the playlists folder without a network request", async () => {
const requests: string[] = [];
const restore = mockFetch((url) => { requests.push(url); return []; });
try {
const items = await new SignPresenterProvider().browse(null, auth);
assert.deepEqual(items, [{ type: "folder", id: "playlists-root", title: "Playlists", path: "/playlists" }]);
assert.equal(requests.length, 0);
} finally {
restore();
}
});

test("browse /playlists lists leaf folders from /content/playlists (array and wrapper shapes)", async () => {
for (const body of [playlists, { playlists }]) {
const requests: string[] = [];
const restore = mockFetch((url) => { requests.push(url); return body; });
try {
const items = await new SignPresenterProvider().browse("/playlists", auth);
assert.equal(requests[0], "https://api.signpresenter.com/content/playlists");
assert.deepEqual(items.map(i => [i.type, i.id, i.path, (i as any).isLeaf]), [["folder", "99", "/playlists/99", true], ["folder", "100", "/playlists/100", true]]);
assert.equal(items[0].thumbnail, "https://x/announcements.jpg");
} finally {
restore();
}
}
});

test("browse a playlist maps V2 slides to media files, skipping non-playable slide types", async () => {
const requests: string[] = [];
const restore = mockFetch((url) => { requests.push(url); return v2Playlist; });
try {
const files = await new SignPresenterProvider().browse("/playlists/99", auth) as any[];

assert.equal(requests[0], "https://api.signpresenter.com/v2/playlists/99");
// Generic web, pdf, html, and multi_zone slides are dropped; the gallery message yields one file per slide
assert.deepEqual(files.map(f => [f.id, f.mediaType]), [
["99-0-0", "image"],
["99-1-0", "video"],
["99-2-0", "video"],
["99-3-0", "image"],
["99-8-0", "image"],
["99-8-1", "image"]
]);

const [image, video, stream, webGif] = files;
assert.equal(image.title, "Welcome");
assert.equal(image.url, "https://x/welcome.png");
assert.equal(image.downloadUrl, "https://x/welcome.png");
assert.equal(image.thumbnail, "https://x/welcome-thumb.jpg");
assert.equal(image.seconds, 10);

assert.equal(video.url, "https://x/clip.mp4");
assert.equal(video.loop, true);
assert.equal(video.seconds, 30);

assert.equal(stream.url, "https://stream.mux.com/abc123.m3u8");
assert.equal(webGif.url, "https://x/countdown.gif");

assert.deepEqual(files.slice(4).map(f => f.title), ["Gallery", "Gallery"]);
} finally {
restore();
}
});

test("falls back to /content/playlists/:id/messages when V2 is empty or missing", async () => {
for (const v2Body of [null, { id: "99", name: "Announcements", messages: [] }]) {
const requests: string[] = [];
const restore = mockFetch((url) => {
requests.push(url);
return url.includes("/v2/") ? v2Body : legacyMessages;
});
try {
const files = await new SignPresenterProvider().browse("/playlists/99", auth) as any[];

assert.equal(requests[0], "https://api.signpresenter.com/v2/playlists/99");
assert.equal(requests[1], "https://api.signpresenter.com/content/playlists/99/messages");
// Messages fall back from url to thumbnail to image; ones with no usable url at all are skipped
assert.deepEqual(files.map(f => [f.id, f.url, f.mediaType]), [
["m1", "https://x/a.mp4", "video"],
["m2", "https://x/thumb.jpg", "image"],
["m3", "https://x/image.png", "image"]
]);
assert.equal(files[0].seconds, 12);
assert.equal(files[0].downloadUrl, "https://x/a.mp4");
} finally {
restore();
}
}
});

test("getPlaylist returns files for a playlist path and null above it", async () => {
const restore = mockFetch(() => v2Playlist);
try {
const provider = new SignPresenterProvider();
const files = await provider.getPlaylist("/playlists/99", auth);
assert.equal(files?.length, 6);
assert.equal(await provider.getPlaylist("/playlists", auth), null);
} finally {
restore();
}
});

test("getInstructions wraps playlist files in a header and section via filesToInstructions", async () => {
const restore = mockFetch((url) => (url.includes("/v2/") ? v2Playlist : playlists));
try {
const instructions = await new SignPresenterProvider().getInstructions("/playlists/99", auth);

assert.equal(instructions?.name, "Announcements");
const [header] = instructions!.items;
assert.equal(header.itemType, "header");
assert.equal(header.label, "Announcements");

const [section] = header.children!;
assert.equal(section.itemType, "section");
assert.equal(section.children?.length, 6);

const action = section.children![0];
assert.equal(action.itemType, "action");
assert.equal(action.actionType, "play");
const file = action.children![0];
assert.equal(file.itemType, "file");
assert.equal(file.downloadUrl, "https://x/welcome.png");
assert.equal(file.mediaType, "image");
} finally {
restore();
}
});
Loading