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 CHANGED
@@ -91,7 +91,11 @@ Or if installed from source:
91
91
  pnpm run cli
92
92
  ```
93
93
 
94
- Use arrow keys to navigate results and press Enter to play.
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 # Entry point, daemon/CLI mode switching
160
- ├── roon.ts # Roon API connection and browsing
161
- ├── mpris.ts # MPRIS player, notifications, metadata
162
- ├── socket.ts # Unix socket server
163
- └── cli.ts # Interactive terminal interface
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 playTrack(track) {
112
+ function selectAction() {
113
113
  return __awaiter(this, void 0, void 0, function* () {
114
- console.log(`\n▶️ Playing: ${track.title}${track.subtitle ? ` · ${track.subtitle}` : ""}\n`);
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("✅ Playback started!\n");
148
+ console.log("✅ Success!\n");
122
149
  }
123
150
  catch (error) {
124
- console.error("❌ Failed to play track:", 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 playTrack(selected);
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.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
- const items = ((_a = tracksResult.items) === null || _a === void 0 ? void 0 : _a.map((item) => ({
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
- function playItem(itemKey, sessionKey) {
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 playNowAction = loadResult.items.find((item) => item.hint === "action" &&
179
- (item.title === "Play Now" || item.title === "Play"));
180
- if (playNowAction) {
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: playNowAction.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("Successfully started playback");
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("Could not find Play action or next level");
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.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.5.3"
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
  }
File without changes