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 ---- */