roonpipe 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -7
- package/dist/cli.js +37 -5
- package/dist/image-cache.js +103 -0
- package/dist/roon.js +62 -13
- package/dist/socket.js +1 -1
- package/package.json +4 -3
- /package/{LICENCE → LICENSE} +0 -0
package/README.md
CHANGED
|
@@ -91,7 +91,11 @@ Or if installed from source:
|
|
|
91
91
|
pnpm run cli
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
Features:
|
|
95
|
+
- Use arrow keys to navigate search results
|
|
96
|
+
- Press Enter to select a track
|
|
97
|
+
- Choose an action: Play now, Add to queue, or Play next
|
|
98
|
+
- Press Ctrl+C to exit
|
|
95
99
|
|
|
96
100
|
### Development Mode
|
|
97
101
|
|
|
@@ -140,6 +144,7 @@ Response:
|
|
|
140
144
|
"title": "Let It Be",
|
|
141
145
|
"subtitle": "The Beatles · Let It Be",
|
|
142
146
|
"item_key": "10:0",
|
|
147
|
+
"image": "/home/user/.cache/roonpipe/images/abc123.jpg",
|
|
143
148
|
"sessionKey": "search_1234567890"
|
|
144
149
|
}
|
|
145
150
|
]
|
|
@@ -148,19 +153,46 @@ Response:
|
|
|
148
153
|
|
|
149
154
|
### Play
|
|
150
155
|
|
|
156
|
+
Play a track immediately (preserves current queue):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
echo '{"command":"play","item_key":"10:0","session_key":"search_1234567890","action":"playNow"}' | nc -U /tmp/roonpipe.sock
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Add to the queue:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
echo '{"command":"play","item_key":"10:0","session_key":"search_1234567890","action":"queue"}' | nc -U /tmp/roonpipe.sock
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Play next (add after current track):
|
|
169
|
+
|
|
151
170
|
```bash
|
|
152
|
-
echo '{"command":"play","item_key":"10:0","session_key":"search_1234567890"}' | nc -U /tmp/roonpipe.sock
|
|
171
|
+
echo '{"command":"play","item_key":"10:0","session_key":"search_1234567890","action":"addNext"}' | nc -U /tmp/roonpipe.sock
|
|
153
172
|
```
|
|
154
173
|
|
|
174
|
+
Replace the queue and play immediately:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
echo '{"command":"play","item_key":"10:0","session_key":"search_1234567890","action":"play"}' | nc -U /tmp/roonpipe.sock
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Available actions:
|
|
181
|
+
- `play` (default) — Replace queue and play immediately
|
|
182
|
+
- `playNow` — Play immediately while preserving the queue (adds next and skips)
|
|
183
|
+
- `queue` — Add to the end of the queue
|
|
184
|
+
- `addNext` — Add after the current track
|
|
185
|
+
|
|
155
186
|
## Project Structure
|
|
156
187
|
|
|
157
188
|
```
|
|
158
189
|
src/
|
|
159
|
-
├── index.ts
|
|
160
|
-
├── roon.ts
|
|
161
|
-
├── mpris.ts
|
|
162
|
-
├── socket.ts
|
|
163
|
-
|
|
190
|
+
├── index.ts # Entry point, daemon/CLI mode switching
|
|
191
|
+
├── roon.ts # Roon API connection and browsing
|
|
192
|
+
├── mpris.ts # MPRIS player, notifications, metadata
|
|
193
|
+
├── socket.ts # Unix socket server
|
|
194
|
+
├── image-cache.ts # Album artwork caching
|
|
195
|
+
└── cli.ts # Interactive terminal interface
|
|
164
196
|
```
|
|
165
197
|
|
|
166
198
|
## Contributing
|
package/dist/cli.js
CHANGED
|
@@ -109,19 +109,46 @@ function selectTrack(results) {
|
|
|
109
109
|
}
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
|
-
function
|
|
112
|
+
function selectAction() {
|
|
113
113
|
return __awaiter(this, void 0, void 0, function* () {
|
|
114
|
-
|
|
114
|
+
try {
|
|
115
|
+
const action = yield (0, prompts_1.select)({
|
|
116
|
+
message: "What do you want to do?",
|
|
117
|
+
choices: [
|
|
118
|
+
{ name: "▶️ Play now", value: "playNow" },
|
|
119
|
+
{ name: "📋 Add to queue", value: "queue" },
|
|
120
|
+
{ name: "⏭️ Play next", value: "addNext" },
|
|
121
|
+
new prompts_1.Separator(),
|
|
122
|
+
{ name: "← Back", value: "back" },
|
|
123
|
+
],
|
|
124
|
+
theme: { prefix: "" },
|
|
125
|
+
});
|
|
126
|
+
return action === "back" ? null : action;
|
|
127
|
+
}
|
|
128
|
+
catch (_a) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function playTrack(track, action) {
|
|
134
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
135
|
+
const actionLabels = {
|
|
136
|
+
playNow: "▶️ Playing",
|
|
137
|
+
queue: "📋 Added to queue",
|
|
138
|
+
addNext: "⏭️ Playing next",
|
|
139
|
+
};
|
|
140
|
+
console.log(`\n${actionLabels[action]}: ${track.title}${track.subtitle ? ` · ${track.subtitle}` : ""}\n`);
|
|
115
141
|
try {
|
|
116
142
|
yield sendCommand({
|
|
117
143
|
command: "play",
|
|
118
144
|
item_key: track.item_key,
|
|
119
145
|
session_key: track.sessionKey,
|
|
146
|
+
action,
|
|
120
147
|
});
|
|
121
|
-
console.log("✅
|
|
148
|
+
console.log("✅ Success!\n");
|
|
122
149
|
}
|
|
123
150
|
catch (error) {
|
|
124
|
-
console.error("❌
|
|
151
|
+
console.error("❌ Failed:", error);
|
|
125
152
|
}
|
|
126
153
|
});
|
|
127
154
|
}
|
|
@@ -146,7 +173,12 @@ function startCLI() {
|
|
|
146
173
|
if (selected.subtitle === "__search__") {
|
|
147
174
|
continue;
|
|
148
175
|
}
|
|
149
|
-
yield
|
|
176
|
+
const action = yield selectAction();
|
|
177
|
+
if (action === null) {
|
|
178
|
+
// User chose "Back", continue to track selection
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
yield playTrack(selected, action);
|
|
150
182
|
}
|
|
151
183
|
});
|
|
152
184
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.getImageCachePath = getImageCachePath;
|
|
16
|
+
exports.isImageCached = isImageCached;
|
|
17
|
+
exports.cacheImage = cacheImage;
|
|
18
|
+
exports.cacheImages = cacheImages;
|
|
19
|
+
exports.clearOldCache = clearOldCache;
|
|
20
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
21
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
const CACHE_DIR = node_path_1.default.join(node_os_1.default.homedir(), ".cache", "roonpipe", "images");
|
|
24
|
+
// Ensure the cache directory exists
|
|
25
|
+
if (!node_fs_1.default.existsSync(CACHE_DIR)) {
|
|
26
|
+
node_fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get the local cache path for an image
|
|
30
|
+
*/
|
|
31
|
+
function getImageCachePath(imageKey) {
|
|
32
|
+
return node_path_1.default.join(CACHE_DIR, `${imageKey}.jpg`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check if an image is already cached
|
|
36
|
+
*/
|
|
37
|
+
function isImageCached(imageKey) {
|
|
38
|
+
return node_fs_1.default.existsSync(getImageCachePath(imageKey));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Download and cache an image from Roon Core
|
|
42
|
+
*/
|
|
43
|
+
function cacheImage(imageApi, imageKey) {
|
|
44
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
45
|
+
if (!imageKey) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const cachePath = getImageCachePath(imageKey);
|
|
49
|
+
// Return cached path if already exists
|
|
50
|
+
if (isImageCached(imageKey)) {
|
|
51
|
+
return cachePath;
|
|
52
|
+
}
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
imageApi.get_image(imageKey, { scale: "fit", width: 300, height: 300, format: "image/jpeg" }, (error, _contentType, imageData) => {
|
|
55
|
+
if (error || !imageData) {
|
|
56
|
+
resolve(null);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
node_fs_1.default.writeFileSync(cachePath, imageData);
|
|
61
|
+
resolve(cachePath);
|
|
62
|
+
}
|
|
63
|
+
catch (_a) {
|
|
64
|
+
resolve(null);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Cache multiple images in parallel
|
|
72
|
+
*/
|
|
73
|
+
function cacheImages(imageApi, imageKeys) {
|
|
74
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
75
|
+
const results = new Map();
|
|
76
|
+
const uniqueKeys = [...new Set(imageKeys.filter(Boolean))];
|
|
77
|
+
yield Promise.all(uniqueKeys.map((key) => __awaiter(this, void 0, void 0, function* () {
|
|
78
|
+
const path = yield cacheImage(imageApi, key);
|
|
79
|
+
results.set(key, path);
|
|
80
|
+
})));
|
|
81
|
+
return results;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Clear old cached images (older than specified days)
|
|
86
|
+
*/
|
|
87
|
+
function clearOldCache(maxAgeDays = 30) {
|
|
88
|
+
try {
|
|
89
|
+
const files = node_fs_1.default.readdirSync(CACHE_DIR);
|
|
90
|
+
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
const filePath = node_path_1.default.join(CACHE_DIR, file);
|
|
94
|
+
const stats = node_fs_1.default.statSync(filePath);
|
|
95
|
+
if (now - stats.mtimeMs > maxAgeMs) {
|
|
96
|
+
node_fs_1.default.unlinkSync(filePath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (_a) {
|
|
101
|
+
// Ignore errors during cleanup
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/roon.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
2
11
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
13
|
};
|
|
@@ -16,13 +25,14 @@ const node_roon_api_browse_1 = __importDefault(require("node-roon-api-browse"));
|
|
|
16
25
|
const node_roon_api_image_1 = __importDefault(require("node-roon-api-image"));
|
|
17
26
|
// @ts-expect-error
|
|
18
27
|
const node_roon_api_transport_1 = __importDefault(require("node-roon-api-transport"));
|
|
28
|
+
const image_cache_1 = require("./image-cache");
|
|
19
29
|
let zone = null;
|
|
20
30
|
let coreInstance = null;
|
|
21
31
|
function initRoon(callbacks) {
|
|
22
32
|
const roon = new node_roon_api_1.default({
|
|
23
33
|
extension_id: "com.bluemancz.roonpipe",
|
|
24
34
|
display_name: "RoonPipe",
|
|
25
|
-
display_version: "1.0.
|
|
35
|
+
display_version: "1.0.2",
|
|
26
36
|
publisher: "BlueManCZ",
|
|
27
37
|
email: "your@email.com",
|
|
28
38
|
website: "https://github.com/bluemancz/roonpipe",
|
|
@@ -102,7 +112,7 @@ function searchRoon(query) {
|
|
|
102
112
|
}
|
|
103
113
|
const tracksCategory = (_a = loadResult.items) === null || _a === void 0 ? void 0 : _a.find((item) => item.title === "Tracks");
|
|
104
114
|
if (!tracksCategory) {
|
|
105
|
-
// No tracks category found - return empty array
|
|
115
|
+
// No tracks category found - return an empty array
|
|
106
116
|
resolve([]);
|
|
107
117
|
return;
|
|
108
118
|
}
|
|
@@ -111,28 +121,66 @@ function searchRoon(query) {
|
|
|
111
121
|
reject(browseError);
|
|
112
122
|
return;
|
|
113
123
|
}
|
|
114
|
-
browse.load(Object.assign(Object.assign({}, opts), { item_key: tracksCategory.item_key, offset: 0, count: Math.min(browseResult.list.count, 50) }), (tracksError, tracksResult) => {
|
|
115
|
-
var _a;
|
|
124
|
+
browse.load(Object.assign(Object.assign({}, opts), { item_key: tracksCategory.item_key, offset: 0, count: Math.min(browseResult.list.count, 50) }), (tracksError, tracksResult) => __awaiter(this, void 0, void 0, function* () {
|
|
125
|
+
var _a, _b;
|
|
116
126
|
if (tracksError) {
|
|
117
127
|
reject(tracksError);
|
|
118
128
|
return;
|
|
119
129
|
}
|
|
120
|
-
|
|
130
|
+
// Cache all images in parallel
|
|
131
|
+
const imageKeys = ((_a = tracksResult.items) === null || _a === void 0 ? void 0 : _a.map((item) => item.image_key).filter(Boolean)) || [];
|
|
132
|
+
const imageApi = coreInstance.services.RoonApiImage;
|
|
133
|
+
const cachedImages = yield (0, image_cache_1.cacheImages)(imageApi, imageKeys);
|
|
134
|
+
const items = ((_b = tracksResult.items) === null || _b === void 0 ? void 0 : _b.map((item) => ({
|
|
121
135
|
title: item.title || "Unknown",
|
|
122
136
|
subtitle: item.subtitle || "",
|
|
123
137
|
item_key: item.item_key,
|
|
124
138
|
image_key: item.image_key,
|
|
139
|
+
image: cachedImages.get(item.image_key) || null,
|
|
125
140
|
hint: item.hint,
|
|
126
141
|
sessionKey: sessionKey,
|
|
127
142
|
}))) || [];
|
|
128
143
|
resolve(items);
|
|
129
|
-
});
|
|
144
|
+
}));
|
|
130
145
|
});
|
|
131
146
|
});
|
|
132
147
|
});
|
|
133
148
|
});
|
|
134
149
|
}
|
|
135
|
-
|
|
150
|
+
const ACTION_TITLES = {
|
|
151
|
+
play: ["Play Now", "Play"],
|
|
152
|
+
queue: ["Queue", "Add to Queue"],
|
|
153
|
+
addNext: ["Play From Here", "Add Next"],
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Skip to the next track in the queue
|
|
157
|
+
*/
|
|
158
|
+
function skipToNext() {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
if (!coreInstance || !zone) {
|
|
161
|
+
reject("Not connected");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
coreInstance.services.RoonApiTransport.control(zone, "next", (err) => {
|
|
165
|
+
if (err)
|
|
166
|
+
reject(err);
|
|
167
|
+
else
|
|
168
|
+
resolve();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function playItem(itemKey_1, sessionKey_1) {
|
|
173
|
+
return __awaiter(this, arguments, void 0, function* (itemKey, sessionKey, action = "play") {
|
|
174
|
+
// "playNow" = add next + skip to it (preserves queue)
|
|
175
|
+
if (action === "playNow") {
|
|
176
|
+
yield playItemInternal(itemKey, sessionKey, "addNext");
|
|
177
|
+
yield skipToNext();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
return playItemInternal(itemKey, sessionKey, action);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function playItemInternal(itemKey, sessionKey, action) {
|
|
136
184
|
return new Promise((resolve, reject) => {
|
|
137
185
|
if (!coreInstance) {
|
|
138
186
|
reject("Roon Core not connected");
|
|
@@ -143,6 +191,7 @@ function playItem(itemKey, sessionKey) {
|
|
|
143
191
|
return;
|
|
144
192
|
}
|
|
145
193
|
const browse = coreInstance.services.RoonApiBrowse;
|
|
194
|
+
const actionTitles = ACTION_TITLES[action];
|
|
146
195
|
function loadUntilAction(currentItemKey, depth = 0) {
|
|
147
196
|
if (depth > 5) {
|
|
148
197
|
reject("Too many levels, cannot find action");
|
|
@@ -175,20 +224,20 @@ function playItem(itemKey, sessionKey) {
|
|
|
175
224
|
reject("No items found");
|
|
176
225
|
return;
|
|
177
226
|
}
|
|
178
|
-
const
|
|
179
|
-
(
|
|
180
|
-
if (
|
|
227
|
+
const targetAction = loadResult.items.find((item) => item.hint === "action" &&
|
|
228
|
+
actionTitles.some((title) => item.title === title));
|
|
229
|
+
if (targetAction) {
|
|
181
230
|
browse.browse({
|
|
182
231
|
hierarchy: "search",
|
|
183
232
|
multi_session_key: sessionKey,
|
|
184
|
-
item_key:
|
|
233
|
+
item_key: targetAction.item_key,
|
|
185
234
|
zone_or_output_id: zone.zone_id,
|
|
186
235
|
}, (playError) => {
|
|
187
236
|
if (playError) {
|
|
188
237
|
reject(playError);
|
|
189
238
|
}
|
|
190
239
|
else {
|
|
191
|
-
console.log(
|
|
240
|
+
console.log(`Successfully executed action: ${action}`);
|
|
192
241
|
resolve();
|
|
193
242
|
}
|
|
194
243
|
});
|
|
@@ -199,7 +248,7 @@ function playItem(itemKey, sessionKey) {
|
|
|
199
248
|
loadUntilAction(actionList.item_key, depth + 1);
|
|
200
249
|
}
|
|
201
250
|
else {
|
|
202
|
-
reject(
|
|
251
|
+
reject(`Could not find ${action} action or next level`);
|
|
203
252
|
}
|
|
204
253
|
}
|
|
205
254
|
});
|
package/dist/socket.js
CHANGED
|
@@ -66,7 +66,7 @@ function startSocketServer(handlers) {
|
|
|
66
66
|
}
|
|
67
67
|
else if (request.command === "play") {
|
|
68
68
|
try {
|
|
69
|
-
yield handlers.play(request.item_key, request.session_key);
|
|
69
|
+
yield handlers.play(request.item_key, request.session_key, request.action);
|
|
70
70
|
client.write(`${JSON.stringify({ error: null, success: true })}\n`);
|
|
71
71
|
}
|
|
72
72
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roonpipe",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Linux integration for Roon – MPRIS support, media keys, desktop notifications, and interactive search CLI",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@types/node-notifier": "^8.0.5",
|
|
26
26
|
"nodemon": "^3.1.11",
|
|
27
27
|
"ts-node": "^10.9.2",
|
|
28
|
-
"typescript": "^5.
|
|
28
|
+
"typescript": "^5.9.3"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist"
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"build": "tsc",
|
|
44
44
|
"start": "node dist/index.js",
|
|
45
45
|
"dev": "nodemon src/index.ts",
|
|
46
|
-
"cli": "tsc && node dist/index.js --cli"
|
|
46
|
+
"cli": "tsc && node dist/index.js --cli",
|
|
47
|
+
"biome": "biome check"
|
|
47
48
|
}
|
|
48
49
|
}
|
/package/{LICENCE → LICENSE}
RENAMED
|
File without changes
|