Skip to content

Commit c70a55c

Browse files
committed
feat: add media browsing and searching features to media-player
Add a new media player example with browsing and searching capabilities. BREAKING CHANGE: remove legacy `EntityCommand` event. This allows overriding entity classes and redefining the command handler. Also the MediaPlayer entity class defines new command handlers for the `browse` and `search` operations. BREAKING CHANGE: fix case in `Messages.getAvailableEntities` enum. Enum is now `GetAvailableEntities`.
1 parent fccb53b commit c70a55c

8 files changed

Lines changed: 959 additions & 28 deletions

File tree

examples/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ and are not yet available as classes.
6363
See `Setting` object definition and the referenced SettingTypeNumber, SettingTypeText, SettingTypeTextArea,
6464
SettingTypePassword, SettingTypeCheckbox, SettingTypeDropdown, SettingTypeLabel.
6565

66+
## media-player
67+
68+
The [media-player](media-player/media_player.js) example shows how to implement a media player driver with media
69+
browsing and searching capabilities.
70+
It extends the `MediaPlayer` entity to override the command handler and the media callback methods.
71+
6672
## Driver configuration
6773

6874
Edit `driver.json` if you'd like to change the port or any other information.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// use integration library in a client project:
2+
// import * as uc from "@unfoldedcircle/integration-api";
3+
// individual classes and enums can also be imported
4+
import * as uc from "../../dist/cjs/index.js";
5+
import {
6+
BrowseMediaItem,
7+
BrowseResult,
8+
KnownMediaClass,
9+
KnownMediaContentType,
10+
MediaPlayerAttributes,
11+
MediaPlayerDeviceClasses,
12+
MediaPlayerFeatures,
13+
MediaPlayerStates
14+
} from "../../dist/cjs/index.js";
15+
16+
const driver = new uc.IntegrationAPI();
17+
18+
driver.init("media_player.json");
19+
20+
driver.on(uc.Events.Connect, async () => {
21+
await driver.setDeviceState(uc.DeviceStates.Connected);
22+
});
23+
24+
driver.on(uc.Events.Disconnect, async () => {
25+
await driver.setDeviceState(uc.DeviceStates.Disconnected);
26+
});
27+
28+
class MediaPlayer extends uc.MediaPlayer {
29+
constructor() {
30+
super(
31+
"test_mediaplayer",
32+
{ en: "Foobar MediaPlayer" },
33+
{
34+
features: [
35+
MediaPlayerFeatures.OnOff,
36+
MediaPlayerFeatures.Dpad,
37+
MediaPlayerFeatures.Home,
38+
MediaPlayerFeatures.Menu,
39+
MediaPlayerFeatures.ChannelSwitcher,
40+
MediaPlayerFeatures.SelectSource,
41+
MediaPlayerFeatures.PlayPause,
42+
MediaPlayerFeatures.PlayMedia,
43+
MediaPlayerFeatures.PlayMediaAction,
44+
MediaPlayerFeatures.ClearPlaylist,
45+
MediaPlayerFeatures.BrowseMedia,
46+
MediaPlayerFeatures.SearchMedia,
47+
MediaPlayerFeatures.SearchMediaClasses
48+
],
49+
attributes: {
50+
[MediaPlayerAttributes.State]: MediaPlayerStates.Off,
51+
[MediaPlayerAttributes.SourceList]: ["Radio", "Streaming", "Favorite 1", "Favorite 2", "Favorite 3"],
52+
[MediaPlayerAttributes.SearchMediaClasses]: [
53+
KnownMediaClass.Album,
54+
KnownMediaClass.Artist,
55+
KnownMediaClass.Music,
56+
KnownMediaClass.Radio
57+
]
58+
},
59+
deviceClass: MediaPlayerDeviceClasses.StreamingBox
60+
}
61+
);
62+
}
63+
64+
async command(cmdId, params) {
65+
const parameters = params ? JSON.stringify(params) : "";
66+
console.log(`Got media-player command request: ${cmdId} ${parameters}`);
67+
68+
return uc.StatusCodes.Ok;
69+
}
70+
71+
async browse(options) {
72+
const paging = options.paging;
73+
console.log(
74+
"Media browsing request for %s: media_id=%s, media_type=%s, offset=%d, limit=%d",
75+
this.id,
76+
options.media_id,
77+
options.media_type,
78+
paging.offset,
79+
paging.limit
80+
);
81+
82+
if (options.media_id === undefined && options.media_type === undefined) {
83+
if (paging.page > 1) {
84+
return BrowseResult.empty();
85+
}
86+
87+
return this.browseRoot(paging);
88+
}
89+
90+
switch (options.media_id) {
91+
case "favorites":
92+
return this.browseFavorites(paging);
93+
case "radio":
94+
return this.browseRadio(paging);
95+
case "albums":
96+
return this.browseAlbums(paging);
97+
default:
98+
return BrowseResult.empty();
99+
}
100+
}
101+
102+
async search(options) {
103+
const paging = options.paging;
104+
console.log(
105+
"Media searching request for %s: '%s', media_id=%s, media_type=%s, filter=%s, offset=%d, limit=%d",
106+
this.id,
107+
options.query,
108+
options.media_id,
109+
options.media_type,
110+
JSON.stringify(options.filter),
111+
paging.offset,
112+
paging.limit
113+
);
114+
115+
return uc.StatusCodes.NotImplemented;
116+
}
117+
118+
browseRoot(paging) {
119+
const root = new BrowseMediaItem("", "Media Directory", {
120+
media_class: KnownMediaClass.Directory,
121+
can_browse: true,
122+
items: [
123+
new BrowseMediaItem("favorites", "Favorites", {
124+
media_class: KnownMediaClass.Directory,
125+
can_browse: true,
126+
can_play: true
127+
}),
128+
new BrowseMediaItem("radio", "Radio", {
129+
media_class: KnownMediaClass.Radio,
130+
can_browse: true
131+
}),
132+
new BrowseMediaItem("albums", "Albums", {
133+
media_class: KnownMediaClass.Directory,
134+
can_browse: true
135+
})
136+
]
137+
});
138+
return new BrowseResult(root, new uc.Pagination(1, root.items.length, root.items.length));
139+
}
140+
141+
browseFavorites(paging) {
142+
const maxFavorites = 99;
143+
const items = [];
144+
for (let i = paging.offset; i < paging.offset + paging.limit; i++) {
145+
const favId = i + 1;
146+
if (favId > maxFavorites) {
147+
break;
148+
}
149+
items.push(
150+
new BrowseMediaItem(`library://favorite/${favId}`, `Favorite ${favId}`, {
151+
media_class: KnownMediaClass.Directory,
152+
media_type: KnownMediaContentType.Music,
153+
can_play: true
154+
})
155+
);
156+
}
157+
const favorites = new BrowseMediaItem("favorites", "Favorites", {
158+
media_class: KnownMediaClass.Directory,
159+
can_browse: true,
160+
can_play: true,
161+
items: items
162+
});
163+
return BrowseResult.fromPaging(favorites, paging, maxFavorites);
164+
}
165+
166+
browseRadio(paging) {
167+
const radios = [
168+
new BrowseMediaItem("library://radio/1", "RTS Couleur 3", {
169+
media_class: KnownMediaClass.Radio,
170+
media_type: KnownMediaContentType.Radio,
171+
can_play: true
172+
}),
173+
new BrowseMediaItem("library://radio/3", "Bassdrive", {
174+
media_class: KnownMediaClass.Radio,
175+
media_type: KnownMediaContentType.Radio,
176+
can_play: true
177+
}),
178+
new BrowseMediaItem("library://radio/4", "BBC Radio 1", {
179+
media_class: KnownMediaClass.Radio,
180+
media_type: KnownMediaContentType.Radio,
181+
can_play: true
182+
})
183+
];
184+
const items = [];
185+
for (let i = paging.offset; i < paging.offset + paging.limit; i++) {
186+
if (i < radios.length) {
187+
items.push(radios[i]);
188+
}
189+
}
190+
191+
const radio = new BrowseMediaItem("radio", "Radio", {
192+
media_class: KnownMediaClass.Directory,
193+
can_browse: true,
194+
items: items
195+
});
196+
return BrowseResult.fromPaging(radio, paging, 3);
197+
}
198+
199+
browseAlbums(paging) {
200+
const maxAlbums = 1234;
201+
const items = [];
202+
for (let i = paging.offset; i < paging.offset + paging.limit; i++) {
203+
const albumId = i + 1;
204+
if (albumId > maxAlbums) {
205+
break;
206+
}
207+
items.push(
208+
new BrowseMediaItem(`library://album/${albumId}`, `Album ${albumId}`, {
209+
media_class: KnownMediaClass.Album,
210+
media_type: KnownMediaContentType.Album,
211+
can_play: true
212+
})
213+
);
214+
}
215+
const albums = new BrowseMediaItem("albums", "Albums", {
216+
media_class: KnownMediaClass.Directory,
217+
can_browse: true,
218+
items: items
219+
});
220+
return BrowseResult.fromPaging(albums, paging, maxAlbums);
221+
}
222+
}
223+
224+
// add a media-player entity
225+
const mediaPlayerEntity = new MediaPlayer();
226+
227+
driver.addAvailableEntity(mediaPlayerEntity);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"driver_id": "media_player_test",
3+
"version": "1.0.0",
4+
"min_core_api": "1.0.0",
5+
"name": {
6+
"en": "Simulated media-player driver",
7+
"de": "Simulierter Media-Player Treiber"
8+
},
9+
"icon": "custom:my_driver_icon",
10+
"description": { "en": "A simple demo integration with a simulated media-player entity." },
11+
"#driver_url": "OPTIONAL: the remote uses the driver URL from mDNS if `driver_url` is missing, untouched if starting with `ws://` or `wss://`, otherwise dynamically set from determined os hostname and port setting below",
12+
"port": "9980",
13+
"developer": {
14+
"name": "John Doe",
15+
"email": "john@doe.com",
16+
"url": "https://www.unfoldedcircle.com"
17+
},
18+
"home_page": "https://www.unfoldedcircle.com",
19+
"release_date": "2026-03-13"
20+
}

index.ts

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import { filterBase64Images, getDefaultLanguageString, toLanguageObject } from "
1515

1616
import * as ui from "./lib/entities/ui.js";
1717
import * as api from "./lib/api_definitions.js";
18+
import * as msg from "./lib/msg_definitions.js";
1819
import { Entities } from "./lib/entities/entities.js";
19-
import { Entity } from "./lib/entities/entity.js";
20+
import { Entity, EntityType } from "./lib/entities/entity.js";
21+
import { MediaPlayer } from "./lib/entities/media_player.js";
2022

2123
/**
2224
* Internal WebSocket handle.
@@ -388,7 +390,7 @@ class IntegrationAPI extends EventEmitter {
388390
await this.#sendResponse(wsHandle, api.MsgEvents.DeviceState, this.#getDeviceState());
389391
break;
390392

391-
case api.Messages.getAvailableEntities:
393+
case api.Messages.GetAvailableEntities:
392394
await this.#sendResponse(wsHandle, api.MsgEvents.AvailableEntities, {
393395
available_entities: this.#getAvailableEntities()
394396
});
@@ -427,6 +429,15 @@ class IntegrationAPI extends EventEmitter {
427429
await this.driverSetupError(wsHandle);
428430
}
429431
break;
432+
433+
case api.Messages.BrowseMedia:
434+
await this.#browseMedia(wsHandle, msgData);
435+
break;
436+
437+
case api.Messages.SearchMedia:
438+
await this.#searchMedia(wsHandle, msgData);
439+
break;
440+
430441
default:
431442
log.warn(`[${wsId}] Unhandled request: ${msg}`);
432443
await this.#sendErrorResult(wsHandle);
@@ -586,15 +597,77 @@ class IntegrationAPI extends EventEmitter {
586597
return;
587598
}
588599

589-
if (!entity.hasCmdHandler) {
590-
// legacy: emit event, so the driver can act on it
600+
const result = await entity.command(cmdId, "params" in data ? data.params : undefined);
601+
await this.acknowledgeCommand(wsHandle, result);
602+
}
603+
604+
async #browseMedia(wsHandle: WsHandle, data: any) {
605+
if (!data) {
606+
log.warn("Ignoring browse media: called with empty msg_data");
607+
await this.acknowledgeCommand(wsHandle, api.StatusCodes.BadRequest);
608+
return;
609+
}
610+
611+
const entityId = data.entity_id;
612+
if (!entityId) {
613+
log.warn("Ignoring browse media: missing entity_id");
614+
await this.acknowledgeCommand(wsHandle, api.StatusCodes.BadRequest);
615+
return;
616+
}
617+
618+
const entity = this.#configuredEntities.getEntity(entityId);
619+
if (!entity || entity.entity_type !== EntityType.MediaPlayer || !(entity instanceof MediaPlayer)) {
620+
log.warn("Cannot browse media for '%s': no configured entity found or entity is not a media-player", entityId);
621+
await this.acknowledgeCommand(wsHandle, api.StatusCodes.NotFound);
622+
return;
623+
}
624+
625+
const request = data as msg.BrowseMediaMsgData;
626+
const paging = api.Paging.fromOptions(request.paging);
627+
const result = await entity.browse({ media_id: request.media_id, media_type: request.media_type, paging });
628+
if (typeof result === "number") {
629+
await this.acknowledgeCommand(wsHandle, result as api.StatusCodes);
630+
} else {
631+
await this.#sendResponse(wsHandle, api.MsgEvents.MediaBrowse, result);
632+
}
633+
}
634+
635+
async #searchMedia(wsHandle: WsHandle, data: any) {
636+
if (!data) {
637+
log.warn("Ignoring search media: called with empty msg_data");
638+
await this.acknowledgeCommand(wsHandle, api.StatusCodes.BadRequest);
639+
return;
640+
}
641+
642+
const request = data as msg.SearchMediaMsgData;
643+
if (!request || !request.entity_id || !request.query) {
644+
log.warn("Ignoring search media: missing entity_id or search_query");
645+
await this.acknowledgeCommand(wsHandle, api.StatusCodes.BadRequest);
646+
return;
647+
}
648+
649+
const entity = this.#configuredEntities.getEntity(request.entity_id);
650+
if (!entity || entity.entity_type !== EntityType.MediaPlayer || !(entity instanceof MediaPlayer)) {
591651
log.warn(
592-
`DEPRECATED no entity command handler provided for ${data.entity_id} by the driver: please migrate the integration driver, the legacy ENTITY_COMMAND event will be removed in a future release!`
652+
"Cannot search media for '%s': no configured entity found or entity is not a media-player",
653+
request.entity_id
593654
);
594-
this.emit(api.Events.EntityCommand, wsHandle, data.entity_id, data.entity_type, data.cmd_id, data.params);
655+
await this.acknowledgeCommand(wsHandle, api.StatusCodes.NotFound);
656+
return;
657+
}
658+
659+
const paging = api.Paging.fromOptions(request.paging);
660+
const result = await entity.search({
661+
query: request.query,
662+
media_id: request.media_id,
663+
media_type: request.media_type,
664+
filter: request.filter,
665+
paging
666+
});
667+
if (typeof result === "number") {
668+
await this.acknowledgeCommand(wsHandle, result as api.StatusCodes);
595669
} else {
596-
const result = await entity.command(cmdId, "params" in data ? data.params : undefined);
597-
await this.acknowledgeCommand(wsHandle, result);
670+
await this.#sendResponse(wsHandle, api.MsgEvents.MediaSearch, result);
598671
}
599672
}
600673

0 commit comments

Comments
 (0)