2 Commits

10 changed files with 429 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
"use strict";
// Dynamically load skills after BE startup.
// This avoids needing to rebuild the big BE bundle when adding new skills on disk.
const path = require("path");
const jibo = require("jibo");
let rlog = null;
try {
rlog = require("./robot-logger");
} catch (e) {
rlog = null;
}
function log(msg, data) {
const line = "[DYN-SKILLS] " + msg;
try {
console.log(line, data || "");
} catch (e) {}
try {
if (rlog && typeof rlog.raw === "function") rlog.raw(line + (data ? " " + JSON.stringify(data) : ""));
} catch (e) {}
}
function normalizeSkillExport(SkillExport, id) {
if (typeof SkillExport === "function") return SkillExport;
if (SkillExport && typeof SkillExport.Skill === "function") return SkillExport.Skill;
throw new Error("Error loading skill: " + id + ". Incorrect exports");
}
function attachLifecycleHooks(be, id, skill) {
// Mirror the hooks the Be constructor normally installs.
try {
skill.on("exit", function () {
be.exit.call(be, skill, ...arguments);
});
skill.on("redirect", function () {
be.skillRedirect.call(be, skill, ...arguments);
});
skill.on("refresh", function () {
be.skillRedirect.call(be, skill, skill.assetPack, ...arguments);
});
} catch (e) {
log("failed to attach lifecycle hooks", { id: id, err: String(e && (e.stack || e.message || e)) });
}
const empty = (done) => {
try {
done();
} catch (e) {}
};
try {
if (!skill.postInit) skill.postInit = empty;
if (!skill.preload) skill.preload = empty;
} catch (e) {}
}
function tryLoadSkill(be, id) {
if (!be || !be.skills) throw new Error("be.skills missing");
if (!id || typeof id !== "string") return false;
if (be.skills[id]) return true;
// eslint-disable-next-line global-require, import/no-dynamic-require
const SkillExport = require(id);
const Skill = normalizeSkillExport(SkillExport, id);
const rootPath = path.dirname(jibo.utils.PathUtils.resolve(id));
const skill = new Skill({ assetPack: id, rootPath: rootPath });
if (typeof be._validateSkill === "function" && !be._validateSkill(skill)) {
throw new Error("not a valid BeSkill");
}
be.skills[id] = skill;
attachLifecycleHooks(be, id, skill);
log("loaded", { id: id });
return true;
}
function collectSkillIdsFromMenuEntries(entries) {
const ids = new Set();
(entries || []).forEach(function (e) {
if (!e) return;
if (e.type === "skill" && e.skillId) ids.add(e.skillId);
if (e.type === "submenu" && Array.isArray(e.children)) {
e.children.forEach(function (c) {
if (c && c.type === "skill" && c.skillId) ids.add(c.skillId);
});
}
});
return Array.from(ids);
}
function loadMissingSkillsFromMenuEntries(be, opts) {
opts = opts || {};
let menuEntries = null;
try {
// Note: this is the modular path (includes provider entries).
menuEntries = require("../menu/menu-entries");
} catch (e) {
log("menu-entries module missing", { err: String(e && (e.stack || e.message || e)) });
return { loaded: [], failed: [] };
}
let res;
try {
res = menuEntries.getMenuEntries({
skillsRoot: opts.skillsRoot,
providersDir: opts.providersDir,
log: function () {
try {
log(Array.prototype.join.call(arguments, " "));
} catch (e) {}
}
});
} catch (e) {
log("getMenuEntries failed", { err: String(e && (e.stack || e.message || e)) });
return { loaded: [], failed: [] };
}
const ids = collectSkillIdsFromMenuEntries(res && res.entries);
log("menu referenced skillIds", { count: ids.length });
const loaded = [];
const failed = [];
ids.forEach(function (id) {
try {
// Only attempt ids that look like NPM packages (reduces noise).
if (typeof id !== "string") return;
if (id.indexOf("/") === -1) return;
if (!be.skills[id]) {
tryLoadSkill(be, id);
loaded.push(id);
}
} catch (e) {
failed.push({ id: id, err: String(e && (e.stack || e.message || e)) });
log("load failed", { id: id, err: String(e && (e.message || e)) });
}
});
return { loaded: loaded, failed: failed, providersDir: res && res.providersDir, skillsRoot: res && res.skillsRoot };
}
module.exports = {
loadMissingSkillsFromMenuEntries: loadMissingSkillsFromMenuEntries
};

View File

@@ -54,6 +54,26 @@ exports.postInit = function (err) {
this.log.warn('Dynamic skills patch failed (non-fatal):', e.message || e);
}
// Optional: dynamically load skills referenced by modular menu entries.
// This is the missing piece for “menu entry exists” => “skill is launchable”.
try {
const dynSkills = require('./dynamic-skills');
const res = dynSkills.loadMissingSkillsFromMenuEntries(this);
try {
this.log.info('Dynamic skill load complete', {
loaded: res && res.loaded ? res.loaded.length : 0,
failed: res && res.failed ? res.failed.length : 0,
skillsRoot: res && res.skillsRoot,
providersDir: res && res.providersDir
});
} catch (e2) {
// ignore
}
}
catch (e) {
this.log.warn('Dynamic skill loader failed (non-fatal):', e.message || e);
}
// Optional: AI Bridge (modular; can run models off-robot for now)
try {
if (rlog && typeof rlog.raw === 'function') {

View File

@@ -0,0 +1,44 @@
# Plex Music (Jibo) — task list
Goal: add a Jibo menu button that opens a Plex music browser UI. Next phase will be playback.
## Phase 0 — Get a basic GUI up (now)
- [x] Create launchable skill module `@be/plex-music`
- [x] Add modular menu entry in `@be/menu-entries.d/`
- [x] Show a simple `MenuView` with placeholder actions
## Phase 1 — Connect to Plex (discovery + auth)
- [ ] Decide config source for server + token
- Option A: JSON file on-robot (e.g. `/opt/jibo/Jibo/Skills/@be/plex-config.json`)
- Option B: env vars (`PLEX_BASE_URL`, `PLEX_TOKEN`)
- Option C: run a tiny local helper service and talk to it
- [ ] Implement a minimal Plex client (HTTP GET)
- [ ] Fetch `/:/resources` or `/?X-Plex-Token=...` health check
- [ ] Handle HTTPS vs HTTP and timeouts
- [ ] Add a “connection status” indicator in the UI
- [ ] Log failures to robot logd (UDP) for easy debugging
## Phase 2 — Browse music library (read-only)
- [ ] List music libraries (Plex sections)
- [ ] Pick a library (default to first music section)
- [ ] Browse hierarchy (at minimum)
- [ ] Artists list
- [ ] Albums for artist
- [ ] Tracks for album
- [ ] Add simple paging/scroll behavior (Jibo screen limits)
- [ ] Cache results in-memory for snappy navigation
## Phase 3 — Playback (next)
- [ ] Decide playback strategy
- Option A: stream directly and use Jibo audio APIs
- Option B: ask Plex to transcode to a simple stream
- Option C: route through an on-robot helper (Python) that downloads/streams
- [ ] Add “Play / Pause / Next” UI
- [ ] Maintain a queue (album or playlist)
- [ ] Show “Now Playing” (track title / artist / album)
## Phase 4 — Polish
- [ ] Better icons + labels
- [ ] Remember last-used library and last selection
- [ ] Handle token expiration gracefully
- [ ] Add a tiny test harness (run on dev box against Plex)

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Plex Music</title>
</head>
<body>
<div id="face"></div>
<script src="index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,128 @@
"use strict";
const jibo = require("jibo");
const beFramework = require("@be/be-framework");
let rlog = null;
try {
// Optional: log to UDP logd if available
rlog = require("@be/be/be/robot-logger");
} catch (e) {
rlog = null;
}
function log(line, data) {
const msg = `[plex-music] ${line}`;
try {
console.log(msg, data || "");
} catch (e) {}
try {
if (rlog && typeof rlog.info === "function") {
rlog.info("plex-music", line, data || undefined);
}
} catch (e) {}
}
class PlexMusic extends beFramework.BeSkill {
constructor(assetPack) {
super(assetPack);
this._menuView = null;
this._onSelect = this._onSelect.bind(this);
}
open(result, refresh) {
log("open", { refresh: !!refresh });
const changeViewOptions = {
addView: "resources/views/menu.json",
transitionOpen: jibo.face.views.UP,
transitionClose: jibo.face.views.UP
};
const onComplete = () => {
log("menu view complete");
};
const onFailure = (err) => {
log("changeView failure", { err: String(err && (err.stack || err.message || err)) });
try {
this.exit();
} catch (e) {}
};
const onLoaded = (view) => {
this._menuView = view || null;
try {
if (this._menuView && typeof this._menuView.on === "function") {
this._menuView.on("select", this._onSelect);
}
} catch (e) {
log("failed to bind select handler", { err: String(e && (e.stack || e.message || e)) });
}
};
try {
jibo.face.views.changeView(changeViewOptions, onComplete, onFailure, onLoaded);
} catch (e) {
onFailure(e);
}
}
_onSelect(selection) {
const id = selection && (selection.id || (selection.data && selection.data.id));
log("select", { id: id });
if (id === "back") {
try {
this.exit();
} catch (e) {}
return;
}
// For now this is just a GUI stub; real Plex logic comes next.
if (id === "connect") {
log("connect placeholder");
return;
}
if (id === "browse") {
log("browse placeholder");
return;
}
}
close(done) {
log("close");
try {
if (this._menuView && typeof this._menuView.off === "function") {
this._menuView.off("select", this._onSelect);
}
} catch (e) {}
this._menuView = null;
const onFailure = (err) => {
log("close changeView failure", { err: String(err && (err.stack || err.message || err)) });
try {
done();
} catch (e) {}
};
try {
jibo.face.views.changeView(
{
removeAll: true,
leaveEmpty: true,
transitionOpen: jibo.face.views.DOWN,
transitionClose: jibo.face.views.DOWN
},
() => done(),
onFailure
);
} catch (e) {
onFailure(e);
}
}
}
module.exports = PlexMusic;

View File

@@ -0,0 +1,21 @@
{
"name": "@be/plex-music",
"version": "0.1.0",
"description": "Plex Music browser (WIP)",
"main": "index.js",
"jibo": {
"main": "index.html",
"type": "asset-pack",
"display-name": "plex-music"
},
"config": {
"standalone": true
},
"dependencies": {
"@be/be-framework": "^13.0.0",
"jibo": "^15.0.0"
},
"engines": {
"node": ">=6.0"
}
}

View File

@@ -0,0 +1,33 @@
{
"viewConfig": {
"type": "MenuView",
"id": "plexMusicMenu",
"title": "Plex Music",
"listDefault": {
"menuButtonType": "SkillButton",
"colors": ["0x9B59B6", "0x6C3483"]
},
"list": [
{
"id": "status",
"label": "Status: not connected",
"iconSrc": "core://resources/actionIcons/cancel.png"
},
{
"id": "connect",
"label": "Configure Plex server",
"iconSrc": "core://resources/actionIcons/ok.png"
},
{
"id": "browse",
"label": "Browse library (placeholder)",
"iconSrc": "core://resources/actionIcons/ok.png"
},
{
"id": "back",
"label": "← Back",
"iconSrc": "core://resources/actionIcons/cancel.png"
}
]
}
}

View File

@@ -33,6 +33,7 @@
"@be/introductions",
"@be/remote",
"@be/nimbus",
"@be/plex-music",
"@be/restore",
"@be/surprises",
"@be/surprises-date",

View File

@@ -0,0 +1,12 @@
[
{
"id": "plex-music",
"type": "skill",
"title": "Plex Music",
"icon": "resources/icons/radio.png",
"color": "purple",
"skillId": "@be/plex-music",
"description": "Browse your Plex music library (WIP)",
"order": 55
}
]

1
V3.1/mode.json Normal file
View File

@@ -0,0 +1 @@
{"mode": "normal"}