diff --git a/src/__tests__/client-unit.test.ts b/src/__tests__/client-unit.test.ts
index e3b51a8..b0ae43e 100644
--- a/src/__tests__/client-unit.test.ts
+++ b/src/__tests__/client-unit.test.ts
@@ -227,3 +227,74 @@ describe('app-level typed errors', () => {
expect(err.message).toBe('test');
});
});
+
+describe('takeScreenshot', () => {
+ const bigImage = Buffer.alloc(2000, 7);
+
+ // Roku's dev-mode screenshot format is device-dependent: HD (720p) TVs write
+ // a JPEG at /pkgs/dev.jpg, newer/4K models write a PNG at /pkgs/dev.png.
+ // `reportedPath` is what the /plugin_inspect POST response advertises;
+ // `servedExt` is the extension the device actually serves a 200 for.
+ function mockScreenshotFetch(reportedPath: string, servedExt: 'jpg' | 'png') {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string | URL) => {
+ const u = url.toString();
+ if (u.includes('/plugin_inspect')) {
+ const body = reportedPath
+ ? `
`
+ : 'Screenshot ok';
+ return new Response(body, { status: 200 });
+ }
+ if (u.includes(`dev.${servedExt}`)) {
+ return new Response(bigImage, { status: 200 });
+ }
+ // Any other extension does not exist on this device model.
+ return new Response('not found', { status: 404 });
+ }),
+ );
+ }
+
+ it('fetches the JPEG path an HD TV reports, never requesting the missing .png', async () => {
+ mockScreenshotFetch('pkgs/dev.jpg', 'jpg');
+ const client = new EcpClient('192.168.0.1');
+ const img = await client.takeScreenshot();
+ expect(img.length).toBe(2000);
+
+ const urls = (fetch as unknown as ReturnType).mock.calls.map((c) =>
+ String(c[0]),
+ );
+ expect(urls.some((u) => u.includes('dev.jpg'))).toBe(true);
+ expect(urls.some((u) => u.includes('dev.png'))).toBe(false);
+ });
+
+ it('falls back to .jpg when the reported .png path 404s', async () => {
+ // Response advertises .png but the device only serves the .jpg.
+ mockScreenshotFetch('pkgs/dev.png', 'jpg');
+ const client = new EcpClient('192.168.0.1');
+ const img = await client.takeScreenshot();
+ expect(img.length).toBe(2000);
+ });
+
+ it('tries both extensions when the response advertises no path', async () => {
+ mockScreenshotFetch('', 'png');
+ const client = new EcpClient('192.168.0.1');
+ const img = await client.takeScreenshot();
+ expect(img.length).toBe(2000);
+ });
+
+ it('throws EcpScreenshotError when no candidate yields an image', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string | URL) => {
+ const u = url.toString();
+ if (u.includes('/plugin_inspect')) {
+ return new Response('', { status: 200 });
+ }
+ return new Response('not found', { status: 404 });
+ }),
+ );
+ const client = new EcpClient('192.168.0.1');
+ await expect(client.takeScreenshot()).rejects.toBeInstanceOf(EcpScreenshotError);
+ });
+});
diff --git a/src/client.ts b/src/client.ts
index 36b0dfa..0fc3717 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -544,7 +544,12 @@ export class EcpClient {
async takeScreenshot(): Promise {
const devUrl = `http://${this.deviceIp}`;
- await digestUpload(
+ // The capture format is device-dependent: HD (720p) Roku TVs write the
+ // screenshot as a JPEG at /pkgs/dev.jpg, while newer/4K models write a PNG
+ // at /pkgs/dev.png. Fetching a hardcoded extension 404s on half of devices,
+ // so we read the actual path the device reports in the POST response HTML
+ // (e.g. src="pkgs/dev.jpg?time=..."), and fall back to trying both.
+ const html = await digestUpload(
`${devUrl}/plugin_inspect`,
'rokudev',
this.devPassword,
@@ -552,17 +557,33 @@ export class EcpClient {
{},
);
- const png = await digestGet(
- `${devUrl}/pkgs/dev.png?time=${Date.now()}`,
- 'rokudev',
- this.devPassword,
- );
+ const reported = html.match(/pkgs\/dev\.(png|jpe?g)/i)?.[0];
+ const candidates = reported
+ ? [reported, ...['pkgs/dev.jpg', 'pkgs/dev.png'].filter((p) => p !== reported)]
+ : ['pkgs/dev.jpg', 'pkgs/dev.png'];
+
+ let image: Buffer | undefined;
+ for (const path of candidates) {
+ try {
+ const data = await digestGet(
+ `${devUrl}/${path}?time=${Date.now()}`,
+ 'rokudev',
+ this.devPassword,
+ );
+ if (data.length >= 1000) {
+ image = data;
+ break;
+ }
+ } catch {
+ // Wrong extension for this device model — try the next candidate.
+ }
+ }
- if (png.length < 1000) {
+ if (!image) {
throw new EcpScreenshotError('Screenshot failed — is a dev channel sideloaded?');
}
- return png;
+ return image;
}
/* ---- SSDP Discovery ---- */