From 135ee9fbf5e8ed24ba5fe0dff47bc6239ff5130d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Ph=E1=BA=A1m?= Date: Tue, 21 Apr 2026 17:26:50 +0700 Subject: [PATCH 1/2] branch the send to JSON playlist and playlist url to support live playlist update --- docs/EXAMPLES.md | 2 +- docs/FUNCTION_CALLING.md | 2 +- docs/README.md | 4 +- index.ts | 12 +++- src/main.ts | 17 +++++- src/utilities/ff1-device.ts | 85 +++++++++++++++++++++++---- src/utilities/functions.js | 4 +- src/utilities/index.js | 5 +- tests/ff1-device-cast-request.test.ts | 44 ++++++++++++++ 9 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 tests/ff1-device-cast-request.test.ts diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 8a5f7cc..c7aa9c3 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -174,7 +174,7 @@ npm run dev -- validate "https://cdn.example.com/playlist.json" # Sign playlist npm run dev -- sign playlist.json -o signed.json -# Send to device +# Send to device (local file: inline JSON; URL: device receives playlistUrl after local verify) npm run dev -- send playlist.json -d "Living Room Display" npm run dev -- send "https://cdn.example.com/playlist.json" -d "Living Room Display" ``` diff --git a/docs/FUNCTION_CALLING.md b/docs/FUNCTION_CALLING.md index 9531422..7abc7b3 100644 --- a/docs/FUNCTION_CALLING.md +++ b/docs/FUNCTION_CALLING.md @@ -49,7 +49,7 @@ Notes enforced by the orchestrator: Located in `src/utilities/` and wired in `src/ai-orchestrator/index.js`: - `buildDP1Playlist({ items, title, slug })` → `src/utilities/playlist-builder.js` -- `sendPlaylistToDevice({ playlist, deviceName })` → `src/utilities/ff1-device.ts` +- `sendPlaylistToDevice({ playlist, deviceName, playlistUrl? })` → `src/utilities/ff1-device.ts` (optional `playlistUrl` for hosted playlists: cast sends the URL instead of embedding `dp1_call` JSON) - `resolveDomains({ domains, displayResults })` → `src/utilities/domain-resolver.ts` - `verifyPlaylist({ playlist })` → `src/utilities/playlist-verifier.ts` - `verifyAddresses({ addresses })` → `src/utilities/functions.js` (uses `address-validator.ts`) diff --git a/docs/README.md b/docs/README.md index 741920f..a4c793d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -101,6 +101,8 @@ npm run build node dist/index.js chat ``` +The `ff1` command runs compiled `dist/index.js`. After pulling or editing code, run `npm run build` again so installs that point at `dist/` pick up changes; `npm run dev` runs TypeScript directly and does not update `dist/`. + If you're running from source without a build, use: ```bash @@ -241,7 +243,7 @@ npm run dev -- send playlist.json -d "Living Room Display" # a clear version message before any cast request is sent. # The send path also retries transient local-network errors (for example intermittent # mDNS/Wi-Fi resolver failures) with a short backoff before returning a final error. -# Send a hosted DP-1 playlist +# Send a hosted DP-1 playlist (verified locally first; device cast uses playlistUrl, not inline JSON) npm run dev -- send "https://cdn.example.com/playlist.json" # Play a direct URL diff --git a/index.ts b/index.ts index e011659..21bfaf1 100644 --- a/index.ts +++ b/index.ts @@ -1018,10 +1018,20 @@ program // eslint-disable-next-line @typescript-eslint/no-require-imports const { sendPlaylistToDevice } = require('./src/utilities/ff1-device'); - // Send the playlist + // Hosted http(s) sources: cast uses `playlistUrl` so the device fetches JSON (CLI still verified above). + // Use `isPlaylistSourceUrl` on the resolved source, not only sourceType, so behavior stays correct if metadata drifts. + const castPlaylistUrl = isPlaylistSourceUrl(playlistResult.source.trim()) + ? playlistResult.source.trim() + : undefined; + + if (castPlaylistUrl) { + console.log(chalk.dim('Cast: device will load playlist from URL (not inline JSON)\n')); + } + const result = await sendPlaylistToDevice({ playlist, deviceName: options.device, + playlistUrl: castPlaylistUrl, }); if (result.success) { diff --git a/src/main.ts b/src/main.ts index 931427a..95ce374 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ import type { BuildPlaylistResult, Playlist, } from './types'; +import { isPlaylistSourceUrl } from './utilities/playlist-source'; // Lazy load utilities and orchestrator to avoid circular dependencies // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -275,9 +276,15 @@ export async function buildPlaylist( }; } + const sendSource = + typeof confirmation.filePath === 'string' ? confirmation.filePath.trim() : ''; + const playlistUrlForCast = + sendSource && isPlaylistSourceUrl(sendSource) ? sendSource : undefined; + const sendResult = await utilities.sendToDevice( confirmation.playlist as Playlist, - confirmation.deviceName + confirmation.deviceName, + playlistUrlForCast ); if (sendResult.success) { @@ -388,12 +395,18 @@ export async function buildPlaylist( console.log(); console.log(chalk.cyan('Sending to device')); + const sendFilePath = + typeof sendParams.filePath === 'string' ? sendParams.filePath.trim() : ''; + const playlistUrlForCast = + sendFilePath && isPlaylistSourceUrl(sendFilePath) ? sendFilePath : undefined; + const sendResult = await utilities.sendToDevice( sendParams.playlist as Playlist, resolveSendPlaylistDeviceName( sendParams.deviceName as string | null | undefined, defaultDeviceName - ) + ), + playlistUrlForCast ); if (sendResult.success) { diff --git a/src/utilities/ff1-device.ts b/src/utilities/ff1-device.ts index 7118b6c..08578fc 100644 --- a/src/utilities/ff1-device.ts +++ b/src/utilities/ff1-device.ts @@ -6,6 +6,7 @@ import * as logger from '../logger'; import type { Playlist } from '../types'; import { assertFF1CommandCompatibility, resolveConfiguredDevice } from './ff1-compatibility'; +import { isPlaylistSourceUrl } from './playlist-source'; const SEND_RETRY_ATTEMPTS = 3; const SEND_RETRY_BASE_DELAY_MS = 750; @@ -13,6 +14,11 @@ const SEND_RETRY_BASE_DELAY_MS = 750; interface SendPlaylistParams { playlist: Playlist; deviceName?: string; + /** + * When set, the cast request uses `playlistUrl` so the device fetches the playlist; + * the CLI still loads and verifies the JSON locally first. + */ + playlistUrl?: string; } interface SendPlaylistResult { @@ -87,6 +93,57 @@ export function isTransientDeviceNetworkError(error: unknown): boolean { return Boolean(causeCode && networkCodes.has(causeCode)); } +type DisplayPlaylistCastRequestBody = { + command: 'displayPlaylist'; + request: { + dp1_call: Playlist; + intent: { action: 'now_display' }; + }; +}; + +type DisplayPlaylistUrlCastRequestBody = { + command: 'displayPlaylist'; + request: { + playlistUrl: string; + intent: { action: 'now_display' }; + }; +}; + +/** + * buildDisplayPlaylistCastRequestBody builds the JSON body for `displayPlaylist` casts. + * Hosted playlists use `playlistUrl` on the wire; local sends embed `dp1_call`. + * + * @param {Object} playlist - Verified DP-1 playlist (always passed for validation/logging callers) + * @param {string} [playlistUrl] - Optional http(s) URL; when set, `dp1_call` is omitted + * @returns {Object} Cast request body for POST /api/cast + */ +export function buildDisplayPlaylistCastRequestBody( + playlist: Playlist, + playlistUrl?: string +): DisplayPlaylistCastRequestBody | DisplayPlaylistUrlCastRequestBody { + const trimmed = playlistUrl?.trim(); + if (trimmed) { + if (!isPlaylistSourceUrl(trimmed)) { + throw new Error('playlistUrl must be an http(s) URL'); + } + return { + command: 'displayPlaylist', + request: { + playlistUrl: trimmed, + intent: { action: 'now_display' }, + }, + }; + } + + return { + command: 'displayPlaylist', + request: { + dp1_call: playlist, + intent: { action: 'now_display' }, + }, + }; +} + /** * Send a DP1 playlist to an FF1 device using the cast API * @@ -98,6 +155,7 @@ export function isTransientDeviceNetworkError(error: unknown): boolean { * @param {Object} params - Function parameters * @param {Object} params.playlist - Complete DP1 v1.0.0 playlist object to send * @param {string} [params.deviceName] - Name of the device to send to (exact match required) + * @param {string} [params.playlistUrl] - When set, send this URL in the cast request instead of embedding `dp1_call` JSON * @returns {Promise} Result object * @returns {boolean} returns.success - Whether the cast was successful * @returns {string} [returns.device] - Device host that received the playlist @@ -121,6 +179,7 @@ export function isTransientDeviceNetworkError(error: unknown): boolean { export async function sendPlaylistToDevice({ playlist, deviceName, + playlistUrl, }: SendPlaylistParams): Promise { try { // Validate input @@ -131,6 +190,17 @@ export async function sendPlaylistToDevice({ }; } + let requestBody: DisplayPlaylistCastRequestBody | DisplayPlaylistUrlCastRequestBody; + try { + requestBody = buildDisplayPlaylistCastRequestBody(playlist, playlistUrl); + console.log('requestBody', requestBody); + } catch (formatError) { + return { + success: false, + error: (formatError as Error).message, + }; + } + const resolved = resolveConfiguredDevice(deviceName); if (!resolved.success || !resolved.device) { return { @@ -149,7 +219,11 @@ export async function sendPlaylistToDevice({ }; } - logger.info(`Sending playlist to FF1 device: ${device.host}`); + if (requestBody.request && 'playlistUrl' in requestBody.request) { + logger.info(`Sending playlist URL to FF1 device: ${device.host}`); + } else { + logger.info(`Sending playlist to FF1 device: ${device.host}`); + } // Construct API URL with optional topicID let apiUrl = `${device.host}/api/cast`; @@ -158,15 +232,6 @@ export async function sendPlaylistToDevice({ logger.debug(`Using topicID: ${device.topicID}`); } - // Wrap playlist in required structure - const requestBody = { - command: 'displayPlaylist', - request: { - dp1_call: playlist, - intent: { action: 'now_display' }, - }, - }; - // Prepare headers const headers: Record = { 'Content-Type': 'application/json', diff --git a/src/utilities/functions.js b/src/utilities/functions.js index 5e6d5fc..85e8997 100644 --- a/src/utilities/functions.js +++ b/src/utilities/functions.js @@ -36,6 +36,7 @@ async function buildDP1Playlist(params) { * @param {Object} params - Send parameters * @param {Object} params.playlist - DP1 playlist * @param {string} [params.deviceName] - Device name (null for first device) + * @param {string} [params.playlistUrl] - When set, cast uses playlist URL instead of dp1_call JSON * @returns {Promise} Result * @returns {boolean} returns.success - Whether send succeeded * @returns {string} [returns.deviceHost] - Device host address @@ -45,11 +46,12 @@ async function buildDP1Playlist(params) { * const result = await sendPlaylistToDevice({ playlist, deviceName: 'MyDevice' }); */ async function sendPlaylistToDevice(params) { - const { playlist, deviceName } = params; + const { playlist, deviceName, playlistUrl } = params; const result = await ff1Device.sendPlaylistToDevice({ playlist, deviceName, + playlistUrl, }); if (result.success) { diff --git a/src/utilities/index.js b/src/utilities/index.js index e882fba..31cb918 100644 --- a/src/utilities/index.js +++ b/src/utilities/index.js @@ -436,11 +436,12 @@ async function buildDP1Playlist(items, title, slug) { * * @param {Object} playlist - DP1 playlist * @param {string} [deviceName] - Device name + * @param {string} [playlistUrl] - When set, cast uses this URL instead of embedding playlist JSON * @returns {Promise} Result */ -async function sendToDevice(playlist, deviceName) { +async function sendToDevice(playlist, deviceName, playlistUrl) { const { sendPlaylistToDevice } = require('./functions'); - return await sendPlaylistToDevice({ playlist, deviceName }); + return await sendPlaylistToDevice({ playlist, deviceName, playlistUrl }); } /** diff --git a/tests/ff1-device-cast-request.test.ts b/tests/ff1-device-cast-request.test.ts new file mode 100644 index 0000000..1d6c0db --- /dev/null +++ b/tests/ff1-device-cast-request.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildDisplayPlaylistCastRequestBody } from '../src/utilities/ff1-device'; + +const minimalPlaylist = { + version: '1.0.0', + title: 'T', + items: [], +}; + +describe('buildDisplayPlaylistCastRequestBody', () => { + it('embeds dp1_call when no playlistUrl', () => { + const body = buildDisplayPlaylistCastRequestBody(minimalPlaylist as never); + assert.equal(body.command, 'displayPlaylist'); + assert.ok('dp1_call' in body.request); + assert.equal(body.request.dp1_call, minimalPlaylist); + assert.deepEqual(body.request.intent, { action: 'now_display' }); + }); + + it('uses playlistUrl for http(s) sources', () => { + const url = 'https://cdn.example.com/p.json'; + const body = buildDisplayPlaylistCastRequestBody(minimalPlaylist as never, url); + assert.equal(body.command, 'displayPlaylist'); + assert.ok('playlistUrl' in body.request); + assert.equal(body.request.playlistUrl, url); + assert.deepEqual(body.request.intent, { action: 'now_display' }); + }); + + it('trims playlistUrl', () => { + const body = buildDisplayPlaylistCastRequestBody( + minimalPlaylist as never, + ' https://cdn.example.com/p.json ' + ); + assert.ok('playlistUrl' in body.request); + assert.equal(body.request.playlistUrl, 'https://cdn.example.com/p.json'); + }); + + it('throws for non-http playlistUrl', () => { + assert.throws( + () => buildDisplayPlaylistCastRequestBody(minimalPlaylist as never, 'file:///tmp/x.json'), + /playlistUrl must be an http\(s\) URL/ + ); + }); +}); From 9e4d61724ade33fd6e37c1e97bcaf6b6d91a9249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Ph=E1=BA=A1m?= Date: Wed, 22 Apr 2026 09:36:12 +0700 Subject: [PATCH 2/2] remove log --- src/utilities/ff1-device.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utilities/ff1-device.ts b/src/utilities/ff1-device.ts index 08578fc..48ed736 100644 --- a/src/utilities/ff1-device.ts +++ b/src/utilities/ff1-device.ts @@ -193,7 +193,6 @@ export async function sendPlaylistToDevice({ let requestBody: DisplayPlaylistCastRequestBody | DisplayPlaylistUrlCastRequestBody; try { requestBody = buildDisplayPlaylistCastRequestBody(playlist, playlistUrl); - console.log('requestBody', requestBody); } catch (formatError) { return { success: false,