Skip to content
Draft
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
2 changes: 1 addition & 1 deletion docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
Expand Down
2 changes: 1 addition & 1 deletion docs/FUNCTION_CALLING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
4 changes: 3 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
17 changes: 15 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
84 changes: 74 additions & 10 deletions src/utilities/ff1-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@
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;

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 {
Expand Down Expand Up @@ -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
*
Expand All @@ -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<Object>} Result object
* @returns {boolean} returns.success - Whether the cast was successful
* @returns {string} [returns.device] - Device host that received the playlist
Expand All @@ -121,6 +179,7 @@ export function isTransientDeviceNetworkError(error: unknown): boolean {
export async function sendPlaylistToDevice({
playlist,
deviceName,
playlistUrl,
}: SendPlaylistParams): Promise<SendPlaylistResult> {
try {
// Validate input
Expand All @@ -131,6 +190,16 @@ export async function sendPlaylistToDevice({
};
}

let requestBody: DisplayPlaylistCastRequestBody | DisplayPlaylistUrlCastRequestBody;
try {
requestBody = buildDisplayPlaylistCastRequestBody(playlist, playlistUrl);
} catch (formatError) {
return {
success: false,
error: (formatError as Error).message,
};
}

const resolved = resolveConfiguredDevice(deviceName);
if (!resolved.success || !resolved.device) {
return {
Expand All @@ -149,7 +218,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`;
Expand All @@ -158,15 +231,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<string, string> = {
'Content-Type': 'application/json',
Expand Down
4 changes: 3 additions & 1 deletion src/utilities/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} Result
* @returns {boolean} returns.success - Whether send succeeded
* @returns {string} [returns.deviceHost] - Device host address
Expand All @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions src/utilities/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} 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 });
}

/**
Expand Down
44 changes: 44 additions & 0 deletions tests/ff1-device-cast-request.test.ts
Original file line number Diff line number Diff line change
@@ -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/
);
});
});
Loading