roonpipe 1.0.2 → 1.0.4

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
@@ -11,8 +11,9 @@ A Linux integration layer for [Roon](https://roonlabs.com/) that brings native d
11
11
  - **Desktop Notifications** — Get notified when tracks change, complete with album artwork
12
12
  - **Playback Controls** — Play, pause, stop, skip, seek, volume, shuffle, and loop
13
13
  - **Track Search** — Search your entire Roon library (including TIDAL/Qobuz) via CLI or programmatically
14
+ - **GNOME Search Integration** — Search and play tracks directly from the GNOME overview or search bar
14
15
  - **Unix Socket API** — Integrate with other applications using a simple JSON-based IPC protocol
15
- - **Interactive CLI** — Search and play tracks directly from your terminal
16
+ - **Interactive CLI** — Search and play tracks directly from your terminal with arrow key navigation
16
17
 
17
18
  ## CLI Example
18
19
 
@@ -59,6 +60,16 @@ pnpm install
59
60
  pnpm build
60
61
  ```
61
62
 
63
+ ## GNOME Search Provider
64
+
65
+ To enable searching for tracks directly from the GNOME overview or search bar, install the search provider (requires sudo):
66
+
67
+ ```bash
68
+ sudo ./scripts/install-gnome-search-provider.sh
69
+ ```
70
+
71
+ This will copy the necessary files to system directories and restart the GNOME shell. After installation, you can search for track names in the GNOME search to see RoonPipe results.
72
+
62
73
  ## Usage
63
74
 
64
75
  ### Running the Daemon
@@ -187,12 +198,14 @@ Available actions:
187
198
 
188
199
  ```
189
200
  src/
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
201
+ ├── index.ts # Entry point, daemon/CLI mode switching
202
+ ├── roon.ts # Roon API connection and browsing
203
+ ├── mpris.ts # MPRIS player and metadata
204
+ ├── notification.ts # Desktop notifications
205
+ ├── socket.ts # Unix socket server
206
+ ├── image-cache.ts # Album artwork caching
207
+ ├── gnome-search-provider.ts # GNOME search provider integration
208
+ └── cli.ts # Interactive terminal interface
196
209
  ```
197
210
 
198
211
  ## Contributing
@@ -0,0 +1,5 @@
1
+ [Shell Search Provider]
2
+ DesktopId=com.bluemancz.RoonPipe.desktop
3
+ BusName=com.bluemancz.RoonPipe.SearchProvider
4
+ ObjectPath=/com/bluemancz/RoonPipe/SearchProvider
5
+ Version=2
@@ -0,0 +1,3 @@
1
+ [DBus Service]
2
+ Name=com.bluemancz.RoonPipe.SearchProvider
3
+ Exec=roonpipe
@@ -0,0 +1,8 @@
1
+ [Desktop Entry]
2
+ Name=RoonPipe
3
+ Comment=Search and play music from Roon
4
+ Icon=audio-x-generic
5
+ Type=Application
6
+ Exec=roonpipe %u
7
+ Terminal=false
8
+ Categories=Audio;
package/dist/cli.js CHANGED
@@ -1,184 +1,7 @@
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.startCLI = startCLI;
16
- const node_net_1 = __importDefault(require("node:net"));
17
- const node_readline_1 = __importDefault(require("node:readline"));
18
- const prompts_1 = require("@inquirer/prompts");
19
- const SOCKET_PATH = "/tmp/roonpipe.sock";
20
- // Helper to send command to daemon via socket
21
- function sendCommand(command) {
22
- return new Promise((resolve, reject) => {
23
- const client = node_net_1.default.createConnection(SOCKET_PATH, () => {
24
- client.write(JSON.stringify(command));
25
- });
26
- let data = "";
27
- client.on("data", (chunk) => {
28
- data += chunk.toString();
29
- });
30
- client.on("end", () => {
31
- try {
32
- const response = JSON.parse(data);
33
- if (response.error) {
34
- reject(response.error);
35
- }
36
- else {
37
- resolve(response);
38
- }
39
- }
40
- catch (_a) {
41
- reject("Failed to parse response");
42
- }
43
- });
44
- client.on("error", (err) => {
45
- reject(`Cannot connect to RoonPipe daemon. Is it running?\n${err.message}`);
46
- });
47
- });
48
- }
49
- function searchQuery() {
50
- return __awaiter(this, void 0, void 0, function* () {
51
- const rl = node_readline_1.default.createInterface({
52
- input: process.stdin,
53
- output: process.stdout,
54
- });
55
- return new Promise((resolve) => {
56
- rl.question("🔍 Search for a track: ", (answer) => {
57
- rl.close();
58
- resolve(answer);
59
- });
60
- });
61
- });
62
- }
63
- function search() {
64
- return __awaiter(this, void 0, void 0, function* () {
65
- const query = yield searchQuery();
66
- if (!query.trim()) {
67
- return [];
68
- }
69
- console.log(`\nSearching for "${query}"...\n`);
70
- try {
71
- const response = yield sendCommand({ command: "search", query });
72
- return response.results || [];
73
- }
74
- catch (error) {
75
- console.error("❌ Error:", error);
76
- return [];
77
- }
78
- });
79
- }
80
- function selectTrack(results) {
81
- return __awaiter(this, void 0, void 0, function* () {
82
- const choices = [
83
- ...results.map((result, index) => ({
84
- name: `${result.title} ${result.subtitle ? `· ${result.subtitle}` : ""}`,
85
- value: index,
86
- })),
87
- new prompts_1.Separator(),
88
- { name: "🔍 New search", value: -1 },
89
- { name: "❌ Quit", value: -2 },
90
- ];
91
- try {
92
- const selection = yield (0, prompts_1.select)({
93
- message: "Select a track to play:",
94
- choices,
95
- pageSize: 15,
96
- theme: { prefix: "" },
97
- });
98
- if (selection === -2) {
99
- return null;
100
- }
101
- if (selection === -1) {
102
- return { item_key: "", sessionKey: "", title: "", subtitle: "__search__" };
103
- }
104
- return results[selection];
105
- }
106
- catch (_a) {
107
- // User pressed Ctrl+C
108
- return null;
109
- }
110
- });
111
- }
112
- function selectAction() {
113
- return __awaiter(this, void 0, void 0, function* () {
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`);
141
- try {
142
- yield sendCommand({
143
- command: "play",
144
- item_key: track.item_key,
145
- session_key: track.sessionKey,
146
- action,
147
- });
148
- console.log("✅ Success!\n");
149
- }
150
- catch (error) {
151
- console.error("❌ Failed:", error);
152
- }
153
- });
154
- }
155
- function startCLI() {
156
- return __awaiter(this, void 0, void 0, function* () {
157
- console.log("\n🎵 RoonPipe Interactive Search");
158
- console.log("==============================\n");
159
- let running = true;
160
- while (running) {
161
- const results = yield search();
162
- if (results.length === 0) {
163
- console.log("❌ No tracks found.\n");
164
- continue;
165
- }
166
- console.log(`Found ${results.length} track(s):\n`);
167
- const selected = yield selectTrack(results);
168
- if (selected === null) {
169
- console.log("\nGoodbye! 👋\n");
170
- running = false;
171
- continue;
172
- }
173
- if (selected.subtitle === "__search__") {
174
- continue;
175
- }
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);
182
- }
183
- });
184
- }
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"startCLI",{enumerable:!0,get:function(){return s}});let e=/*#__PURE__*/r(require("node:net")),t=/*#__PURE__*/r(require("node:readline")),n=require("@inquirer/prompts");function r(e){return e&&e.__esModule?e:{default:e}}function o(t){return new Promise((n,r)=>{let o=e.default.createConnection("/tmp/roonpipe.sock",()=>{o.write(JSON.stringify(t))}),a="";o.on("data",e=>{a+=e.toString()}),o.on("end",()=>{try{let e=JSON.parse(a);e.error?r(e.error):n(e)}catch{r("Failed to parse response")}}),o.on("error",e=>{r(`Cannot connect to RoonPipe daemon. Is it running?
2
+ ${e.message}`)})})}async function a(){let e=t.default.createInterface({input:process.stdin,output:process.stdout});return new Promise(t=>{e.question("🔍 Search for a track: ",n=>{e.close(),t(n)})})}async function l(){let e=await a();if(!e.trim())return[];console.log(`
3
+ Searching for "${e}"...
4
+ `);try{return(await o({command:"search",query:e})).results||[]}catch(e){return console.error("❌ Error:",e),[]}}async function i(e){let t=[...e.map((e,t)=>({name:`${e.title} ${e.subtitle?`\xb7 ${e.subtitle}`:""}`,value:t})),new n.Separator,{name:"🔍 New search",value:-1},{name:"❌ Quit",value:-2}];try{let r=await (0,n.select)({message:"Select a track to play:",choices:t,pageSize:15,theme:{prefix:""}});if(-2===r)return null;if(-1===r)return{item_key:"",sessionKey:"",title:"",subtitle:"__search__"};return e[r]}catch{return null}}async function u(){try{let e=await (0,n.select)({message:"What do you want to do?",choices:[{name:"▶️ Play now",value:"playNow"},{name:"📋 Add to queue",value:"queue"},{name:"⏭️ Play next",value:"addNext"},new n.Separator,{name:"← Back",value:"back"}],theme:{prefix:""}});return"back"===e?null:e}catch{return null}}async function c(e,t){console.log(`
5
+ ${{playNow:"▶️ Playing",queue:"📋 Added to queue",addNext:"⏭️ Playing next"}[t]}: ${e.title}${e.subtitle?` \xb7 ${e.subtitle}`:""}
6
+ `);try{await o({command:"play",item_key:e.item_key,session_key:e.sessionKey,action:t}),console.log("✅ Success!\n")}catch(e){console.error("❌ Failed:",e)}}async function s(){console.log("\n🎵 RoonPipe Interactive Search"),console.log("==============================\n");let e=!0;for(;e;){let t=await l();if(0===t.length){console.log("❌ No tracks found.\n");continue}console.log(`Found ${t.length} track(s):
7
+ `);let n=await i(t);if(null===n){console.log("\nGoodbye! 👋\n"),e=!1;continue}if("__search__"===n.subtitle)continue;let r=await u();null!==r&&await c(n,r)}}
@@ -0,0 +1 @@
1
+ "use strict";var e;Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"initGnomeSearchProvider",{enumerable:!0,get:function(){return d}});let t=(e=require("dbus-next"))&&e.__esModule?e:{default:e},r="com.bluemancz.RoonPipe.SearchProvider",n=new Map,a=new Map,o=null,i="",l=null,s=null,u=null;async function c(e){if(console.log("Searching for terms:",e),!s)return[];let t=e.join(" ");return t.length<4?[]:t===i&&l?l:(o&&clearTimeout(o),i=t,l=new Promise(r=>{o=setTimeout(async()=>{if(t!==i)return void r([]);if(a.has(t))return void r(a.get(t));try{console.log("Searching for terms:",e);let o=await s(t),i=[];for(let e of o.slice(0,10)){let t=`roon_${e.item_key}`;n.set(t,e),i.push(t)}a.set(t,i),r(i)}catch(e){console.error("Search failed:",e),r([])}},300)}))}let{Interface:g}=t.default.interface;class S extends g{constructor(){super("org.gnome.Shell.SearchProvider2")}async GetInitialResultSet(e){return c(e)}async GetSubsearchResultSet(e,t){return c(t)}async GetResultMetas(e){let r=t.default.Variant,a=[];for(let t of e){let e=n.get(t);if(e){let n={id:new r("s",t),name:new r("s",e.title),description:new r("s",e.subtitle)};e.image&&(n.gicon=new r("s",e.image)),a.push(n)}}return a}async ActivateResult(e,t,r){console.log("ActivateResult called for:",e);let a=n.get(e);if(console.log("Result to activate:",a),a&&u)try{await u(a.item_key,a.sessionKey,"playNow"),console.log(`Playing: ${a.title}`)}catch(e){console.error("Failed to play track:",e)}}async LaunchSearch(e,t){console.log("LaunchSearch called - not implemented")}}async function d(e,n){s=e,u=n;try{let e=t.default.sessionBus();await e.requestName(r,0);let n=new S;e.export("/com/bluemancz/RoonPipe/SearchProvider",n),console.log("GNOME Search Provider initialized on",r)}catch(e){console.error("Failed to initialize GNOME Search Provider:",e)}}S.configureMembers({methods:{GetInitialResultSet:{inSignature:"as",outSignature:"as"},GetSubsearchResultSet:{inSignature:"asas",outSignature:"as"},GetResultMetas:{inSignature:"as",outSignature:"aa{sv}"},ActivateResult:{inSignature:"sasu",outSignature:""},LaunchSearch:{inSignature:"asu",outSignature:""}}});
@@ -1,103 +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
- };
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
- }
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=exports,t={get cacheImage(){return f},get cacheImages(){return d},get clearOldCache(){return s},get getImageCachePath(){return c},get isImageCached(){return o}};for(var r in t)Object.defineProperty(e,r,{enumerable:!0,get:Object.getOwnPropertyDescriptor(t,r).get});let n=/*#__PURE__*/u(require("node:fs")),a=/*#__PURE__*/u(require("node:os")),i=/*#__PURE__*/u(require("node:path"));function u(e){return e&&e.__esModule?e:{default:e}}let l=i.default.join(a.default.homedir(),".cache","roonpipe","images");function c(e){return i.default.join(l,`${e}.jpg`)}function o(e){return n.default.existsSync(c(e))}async function f(e,t){if(!t)return null;let r=c(t);return o(t)?r:new Promise(a=>{e.get_image(t,{scale:"fit",width:300,height:300,format:"image/jpeg"},(e,t,i)=>{if(e||!i)return void a(null);try{n.default.writeFileSync(r,i),a(r)}catch{a(null)}})})}async function d(e,t){let r=new Map,n=[...new Set(t.filter(Boolean))];return await Promise.all(n.map(async t=>{let n=await f(e,t);r.set(t,n)})),r}function s(e=30){try{let t=n.default.readdirSync(l),r=24*e*36e5,a=Date.now();for(let e of t){let t=i.default.join(l,e),u=n.default.statSync(t);a-u.mtimeMs>r&&n.default.unlinkSync(t)}}catch{}}n.default.existsSync(l)||n.default.mkdirSync(l,{recursive:!0});
package/dist/index.js CHANGED
@@ -1,48 +1,2 @@
1
1
  #!/usr/bin/env node
2
- "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- const cli_1 = require("./cli");
5
- const mpris_1 = require("./mpris");
6
- const roon_1 = require("./roon");
7
- const socket_1 = require("./socket");
8
- // Check if CLI mode is requested
9
- const cliMode = process.argv.includes("--cli");
10
- if (cliMode) {
11
- // CLI mode - just connect to daemon via socket
12
- (0, cli_1.startCLI)();
13
- }
14
- else {
15
- // Daemon mode - check for the existing instance
16
- (0, socket_1.isInstanceRunning)().then((running) => {
17
- if (running) {
18
- console.error("❌ Another instance of RoonPipe is already running.");
19
- console.error(" Stop the existing instance first, or use --cli to connect to it.");
20
- process.exit(1);
21
- }
22
- // Daemon mode - start all services
23
- console.log("Starting RoonPipe Daemon");
24
- // Initialize MPRIS
25
- (0, mpris_1.initMpris)(() => { var _a; return (_a = (0, roon_1.getCore)()) === null || _a === void 0 ? void 0 : _a.services.RoonApiTransport; }, roon_1.getZone);
26
- // Initialize Roon and start socket server
27
- (0, roon_1.initRoon)({
28
- onCorePaired: (_core) => {
29
- (0, socket_1.startSocketServer)({
30
- search: roon_1.searchRoon,
31
- play: roon_1.playItem,
32
- });
33
- },
34
- onCoreUnpaired: (_core) => {
35
- // Clear MPRIS metadata when unpaired
36
- (0, mpris_1.updateMprisMetadata)(null, null);
37
- },
38
- onZoneChanged: (zone, core) => {
39
- // Update MPRIS when zone changes
40
- (0, mpris_1.updateMprisMetadata)(zone, core);
41
- },
42
- onSeekChanged: (seekPosition) => {
43
- // Update MPRIS seek position
44
- (0, mpris_1.updateMprisSeek)(seekPosition);
45
- },
46
- });
47
- });
48
- }
2
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0});let e=require("./cli"),r=require("./gnome-search-provider"),o=require("./mpris"),n=require("./notification"),i=require("./roon"),t=require("./socket");process.argv.includes("--cli")?(0,e.startCLI)():(0,t.isInstanceRunning)().then(e=>{e&&(console.error("❌ Another instance of RoonPipe is already running."),console.error(" Stop the existing instance first, or use --cli to connect to it."),process.exit(1)),console.log("Starting RoonPipe Daemon"),(0,o.initMpris)(()=>(0,i.getCore)()?.services.RoonApiTransport,i.getZone),(0,i.initRoon)({onCorePaired:e=>{(0,t.startSocketServer)({search:i.searchRoon,play:i.playItem}),(process.env.XDG_CURRENT_DESKTOP?.includes("GNOME")||process.env.DESKTOP_SESSION?.includes("gnome"))&&(0,r.initGnomeSearchProvider)(i.searchRoon,i.playItem)},onCoreUnpaired:e=>{(0,o.updateMprisMetadata)(null,null)},onZoneChanged:(e,r)=>{(0,o.updateMprisMetadata)(e,r),(0,n.showTrackNotification)(e,r)},onSeekChanged:e=>{(0,o.updateMprisSeek)(e)}})});
package/dist/mpris.js CHANGED
@@ -1,215 +1 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.initMpris = initMpris;
7
- exports.updateMprisMetadata = updateMprisMetadata;
8
- exports.updateMprisSeek = updateMprisSeek;
9
- const node_fs_1 = __importDefault(require("node:fs"));
10
- // @ts-expect-error
11
- const mpris_service_1 = __importDefault(require("mpris-service"));
12
- const node_notifier_1 = __importDefault(require("node-notifier"));
13
- let mpris = null;
14
- let lastNotifiedTrack = null;
15
- let isInitialLoad = true;
16
- let lastPlaybackState = null;
17
- const artworkPath = "/tmp/roon-mpris-cover.jpg";
18
- const loopMap = {
19
- // Roon -> MPRIS
20
- loop_one: "Track",
21
- loop: "Playlist",
22
- disabled: "None",
23
- next: "None",
24
- // MPRIS -> Roon
25
- Track: "loop_one",
26
- Playlist: "loop",
27
- None: "disabled",
28
- };
29
- function convertRoonLoopToMPRIS(roonLoop) {
30
- return loopMap[roonLoop] || "None";
31
- }
32
- function convertMPRISLoopToRoon(mprisLoop) {
33
- return loopMap[mprisLoop] || "disabled";
34
- }
35
- function initMpris(getTransport, getZone) {
36
- // Helper to call Roon transport functions
37
- function roonCallback(fn) {
38
- const transport = getTransport();
39
- const zone = getZone();
40
- if (transport && zone) {
41
- fn(transport, zone);
42
- }
43
- }
44
- mpris = (0, mpris_service_1.default)({
45
- name: "roon",
46
- identity: "Roon",
47
- supportedUriSchemes: ["file"],
48
- supportedMimeTypes: ["audio/mpeg", "application/ogg"],
49
- supportedInterfaces: ["player"],
50
- });
51
- // Wire up all MPRIS events
52
- const events = [
53
- "play",
54
- "pause",
55
- "stop",
56
- "playpause",
57
- "next",
58
- "previous",
59
- "seek",
60
- "position",
61
- "volume",
62
- "loopStatus",
63
- "shuffle",
64
- "open",
65
- ];
66
- events.forEach((event) => {
67
- mpris.on(event, (data) => {
68
- switch (event) {
69
- case "seek":
70
- roonCallback((t, z) => t.seek(z, "relative", data / 1000000));
71
- break;
72
- case "position":
73
- roonCallback((t, z) => t.seek(z, "absolute", data.position / 1000000));
74
- break;
75
- case "volume":
76
- roonCallback((t, z) => {
77
- var _a, _b;
78
- if (!((_b = (_a = z.outputs) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.volume))
79
- return;
80
- const output = z.outputs[0];
81
- const vol = output.volume;
82
- const roonVolume = vol.min + data * (vol.max - vol.min);
83
- t.change_volume(output, "absolute", Math.round(roonVolume));
84
- });
85
- break;
86
- case "loopStatus":
87
- roonCallback((t, z) => t.change_settings(z, { loop: convertMPRISLoopToRoon(data) }));
88
- break;
89
- case "shuffle":
90
- roonCallback((t, z) => t.change_settings(z, { shuffle: data }));
91
- break;
92
- case "open":
93
- console.log("MPRIS command: open", data.uri);
94
- break;
95
- default:
96
- roonCallback((t, z) => t.control(z, event));
97
- break;
98
- }
99
- });
100
- });
101
- // Position getter - dynamically reads from zone
102
- mpris.getPosition = () => {
103
- var _a;
104
- const zone = getZone();
105
- return ((_a = zone === null || zone === void 0 ? void 0 : zone.now_playing) === null || _a === void 0 ? void 0 : _a.seek_position) ? zone.now_playing.seek_position * 1000000 : 0;
106
- };
107
- // Set initial states
108
- mpris.canControl = true;
109
- mpris.canPlay = true;
110
- mpris.canPause = true;
111
- mpris.canGoNext = true;
112
- mpris.canGoPrevious = true;
113
- mpris.canSeek = true;
114
- mpris.playbackStatus = "Stopped";
115
- console.log("MPRIS player initialized");
116
- }
117
- function fetchArtwork(core, imageKey, callback) {
118
- const image = core.services.RoonApiImage;
119
- image.get_image(imageKey, { scale: "fit", width: 256, height: 256, format: "image/jpeg" }, (error, _contentType, imageData) => {
120
- if (error) {
121
- console.error("Failed to fetch artwork:", error);
122
- return;
123
- }
124
- node_fs_1.default.writeFileSync(artworkPath, imageData);
125
- console.log("Artwork saved to", artworkPath);
126
- callback();
127
- });
128
- }
129
- function showTrackNotification(zone) {
130
- var _a, _b, _c;
131
- if (!zone || !zone.now_playing)
132
- return;
133
- const np = zone.now_playing;
134
- const trackId = np.image_key || "";
135
- const currentState = zone.state;
136
- // Skip notification on an initial load
137
- if (isInitialLoad) {
138
- lastNotifiedTrack = trackId;
139
- lastPlaybackState = currentState;
140
- isInitialLoad = false;
141
- return;
142
- }
143
- // Show notification if the track changed or started playing
144
- const trackChanged = trackId !== lastNotifiedTrack;
145
- const startedPlaying = currentState === "playing" && lastPlaybackState !== "playing";
146
- if (trackChanged || startedPlaying) {
147
- lastNotifiedTrack = trackId;
148
- node_notifier_1.default.notify({
149
- title: ((_a = np.three_line) === null || _a === void 0 ? void 0 : _a.line1) || "Unknown Track",
150
- message: `${((_b = np.three_line) === null || _b === void 0 ? void 0 : _b.line2) || "Unknown Artist"}\n${((_c = np.three_line) === null || _c === void 0 ? void 0 : _c.line3) || ""}`,
151
- timeout: 5,
152
- icon: artworkPath,
153
- hint: "string:x-canonical-private-synchronous:roonpipe",
154
- });
155
- }
156
- lastPlaybackState = currentState;
157
- }
158
- function updateVolume(zone) {
159
- if (!mpris)
160
- return;
161
- if (!zone || !zone.outputs || zone.outputs.length === 0) {
162
- mpris.volume = 0;
163
- return;
164
- }
165
- const output = zone.outputs[0];
166
- if (!output.volume) {
167
- mpris.volume = 0;
168
- return;
169
- }
170
- const vol = output.volume;
171
- mpris.volume = (vol.value - vol.min) / (vol.max - vol.min);
172
- }
173
- function updateMprisMetadata(zone, core) {
174
- var _a, _b, _c;
175
- if (!mpris)
176
- return;
177
- if (!zone || !zone.now_playing) {
178
- mpris.metadata = {};
179
- return;
180
- }
181
- const np = zone.now_playing;
182
- // Fetch artwork if image_key exists
183
- if (np.image_key) {
184
- fetchArtwork(core, np.image_key, () => {
185
- showTrackNotification(zone);
186
- });
187
- }
188
- mpris.metadata = {
189
- "mpris:trackid": mpris.objectPath(`track/${np.image_key || "0"}`),
190
- "xesam:title": ((_a = np.three_line) === null || _a === void 0 ? void 0 : _a.line1) || "Unknown",
191
- "xesam:artist": ((_b = np.three_line) === null || _b === void 0 ? void 0 : _b.line2) ? [np.three_line.line2] : [],
192
- "xesam:album": ((_c = np.three_line) === null || _c === void 0 ? void 0 : _c.line3) || "",
193
- "mpris:length": (np.length || 0) * 1000000,
194
- "mpris:artUrl": `file://${artworkPath}`,
195
- };
196
- // For playpause to work, we need canPlay OR canPause to be true
197
- // When playing: is_pause_allowed=true, is_play_allowed=false
198
- // When paused: is_pause_allowed=false, is_play_allowed=true
199
- // We set both to true if either action is allowed, so playpause always works
200
- const canTogglePlayback = zone.is_play_allowed || zone.is_pause_allowed;
201
- mpris.canPlay = canTogglePlayback;
202
- mpris.canPause = canTogglePlayback;
203
- mpris.canGoNext = zone.is_next_allowed;
204
- mpris.canGoPrevious = zone.is_previous_allowed;
205
- mpris.playbackStatus = zone.state === "playing" ? "Playing" : "Paused";
206
- mpris.loopStatus = convertRoonLoopToMPRIS(zone.settings.loop);
207
- mpris.shuffle = zone.settings.shuffle;
208
- mpris.canSeek = zone.is_seek_allowed;
209
- updateVolume(zone);
210
- }
211
- function updateMprisSeek(seekPosition) {
212
- if (!mpris)
213
- return;
214
- mpris.seeked(seekPosition);
215
- }
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e,t=exports,a={get initMpris(){return u},get updateMprisMetadata(){return p},get updateMprisSeek(){return c}};for(var o in a)Object.defineProperty(t,o,{enumerable:!0,get:Object.getOwnPropertyDescriptor(a,o).get});let s=(e=require("mpris-service"))&&e.__esModule?e:{default:e},i=require("./image-cache"),n=require("./roon"),l=null,r={loop_one:"Track",loop:"Playlist",disabled:"None",next:"None",Track:"loop_one",Playlist:"loop",None:"disabled"};function u(e,t){function a(a){let o=e(),s=t();o&&s&&a(o,s)}l=(0,s.default)({name:"roon",identity:"Roon",supportedUriSchemes:["file"],supportedMimeTypes:["audio/mpeg","application/ogg"],supportedInterfaces:["player"]}),["play","pause","stop","playpause","next","previous","seek","position","volume","loopStatus","shuffle","open"].forEach(e=>{l.on(e,t=>{switch(e){case"seek":a((e,a)=>e.seek(a,"relative",t/1e6));break;case"position":a((e,a)=>e.seek(a,"absolute",t.position/1e6));break;case"volume":a((e,a)=>{if(!a.outputs?.[0]?.volume)return;let o=a.outputs[0],s=o.volume,i=s.min+t*(s.max-s.min);e.change_volume(o,"absolute",Math.round(i))});break;case"loopStatus":a((e,a)=>e.change_settings(a,{loop:r[t]||"disabled"}));break;case"shuffle":a((e,a)=>e.change_settings(a,{shuffle:t}));break;case"open":console.log("MPRIS command: open",t.uri);break;default:a((t,a)=>t.control(a,e))}})}),l.getPosition=()=>{let e=t();return e?.now_playing?.seek_position?1e6*e.now_playing.seek_position:0},l.canControl=!0,l.canPlay=!0,l.canPause=!0,l.canGoNext=!0,l.canGoPrevious=!0,l.canSeek=!0,l.playbackStatus="Stopped",console.log("MPRIS player initialized")}async function p(e,t){if(!l)return;if(!e||!e.now_playing){l.metadata={},l.playbackStatus="Stopped";return}let a=e.now_playing,o=(0,n.parseNowPlaying)(a),s=null;a.image_key&&t?.services?.RoonApiImage&&(s=await (0,i.cacheImage)(t.services.RoonApiImage,a.image_key)),l.metadata={"mpris:trackid":l.objectPath(`track/${a.image_key||"0"}`),"xesam:title":o.title,"xesam:artist":o.artists,"xesam:album":o.album,"mpris:length":1e6*(a.length||0),...s&&{"mpris:artUrl":`file://${s}`}};let u=e.is_play_allowed||e.is_pause_allowed;l.canPlay=u,l.canPause=u,l.canGoNext=e.is_next_allowed,l.canGoPrevious=e.is_previous_allowed,l.playbackStatus="playing"===e.state?"Playing":"Paused",l.loopStatus=r[e.settings.loop]||"None",l.shuffle=e.settings.shuffle,l.canSeek=e.is_seek_allowed,function(e){if(!l)return;if(!e||!e.outputs||0===e.outputs.length){l.volume=0;return}let t=e.outputs[0];if(!t.volume){l.volume=0;return}let a=t.volume;l.volume=(a.value-a.min)/(a.max-a.min)}(e)}function c(e){l&&l.seeked(e)}
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"showTrackNotification",{enumerable:!0,get:function(){return l}});let e=require("node:child_process"),i=require("./image-cache"),n=require("./roon"),t=null,o=null,r=!0,a=null;async function l(l,c){if(!l?.now_playing)return;let s=l.now_playing,u=s.image_key||"",g=l.state;if(r){r=!1,t=u,a=g;return}let p=u!==t,m="playing"===g&&"playing"!==a;a=g,(p||m)&&(o&&clearTimeout(o),o=setTimeout(async()=>{if(u===t){o=null;return}t=u;let r=(0,n.parseNowPlaying)(s),a=null;s.image_key&&c?.services?.RoonApiImage&&(a=await (0,i.cacheImage)(c.services.RoonApiImage,s.image_key)),console.log("Showing notification for track:",r.title);let l=["--app-name=Roon","--icon=audio-x-generic","--hint=int:transient:1",...a?[`--hint=string:image-path:${a}`]:[],"--expire-time=5000",r.title,`${r.artists.join(", ")}
2
+ ${r.album}`];try{(0,e.execSync)(`notify-send ${l.map(e=>`'${e.replace(/'/g,"'\\''")}'`).join(" ")}`)}catch(e){console.error("Failed to show notification:",e)}o=null},150))}
package/dist/roon.js CHANGED
@@ -1,265 +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
- };
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.initRoon = initRoon;
16
- exports.searchRoon = searchRoon;
17
- exports.playItem = playItem;
18
- exports.getZone = getZone;
19
- exports.getCore = getCore;
20
- // @ts-expect-error
21
- const node_roon_api_1 = __importDefault(require("node-roon-api"));
22
- // @ts-expect-error
23
- const node_roon_api_browse_1 = __importDefault(require("node-roon-api-browse"));
24
- // @ts-expect-error
25
- const node_roon_api_image_1 = __importDefault(require("node-roon-api-image"));
26
- // @ts-expect-error
27
- const node_roon_api_transport_1 = __importDefault(require("node-roon-api-transport"));
28
- const image_cache_1 = require("./image-cache");
29
- let zone = null;
30
- let coreInstance = null;
31
- function initRoon(callbacks) {
32
- const roon = new node_roon_api_1.default({
33
- extension_id: "com.bluemancz.roonpipe",
34
- display_name: "RoonPipe",
35
- display_version: "1.0.2",
36
- publisher: "BlueManCZ",
37
- email: "your@email.com",
38
- website: "https://github.com/bluemancz/roonpipe",
39
- core_paired: (core) => {
40
- coreInstance = core;
41
- const transport = core.services.RoonApiTransport;
42
- transport.subscribe_zones((cmd, data) => {
43
- if (cmd === "Subscribed") {
44
- zone = data.zones.find((z) => z.state === "playing") || data.zones[0];
45
- callbacks.onZoneChanged(zone, core);
46
- }
47
- else if (cmd === "Changed") {
48
- if (data.zones_changed) {
49
- const playingZone = data.zones_changed.find((z) => z.state === "playing");
50
- if (playingZone) {
51
- zone = playingZone;
52
- }
53
- else if (zone) {
54
- zone =
55
- data.zones_changed.find((z) => z.zone_id === zone.zone_id) ||
56
- zone;
57
- }
58
- callbacks.onZoneChanged(zone, core);
59
- }
60
- if (data.zones_seek_changed) {
61
- const seekUpdate = data.zones_seek_changed.find((z) => z.zone_id === (zone === null || zone === void 0 ? void 0 : zone.zone_id));
62
- if (seekUpdate && zone) {
63
- zone.now_playing.seek_position = seekUpdate.seek_position;
64
- callbacks.onSeekChanged(seekUpdate.seek_position * 1000000);
65
- }
66
- }
67
- }
68
- });
69
- callbacks.onCorePaired(core);
70
- console.log(`Core paired: ${core.display_name}`);
71
- },
72
- core_unpaired: (core) => {
73
- zone = null;
74
- coreInstance = null;
75
- callbacks.onCoreUnpaired(core);
76
- console.log(`Core unpaired: ${core.display_name}`);
77
- },
78
- });
79
- roon.init_services({
80
- required_services: [node_roon_api_browse_1.default, node_roon_api_image_1.default, node_roon_api_transport_1.default],
81
- });
82
- roon.start_discovery();
83
- }
84
- function searchRoon(query) {
85
- return new Promise((resolve, reject) => {
86
- if (!coreInstance) {
87
- reject("Roon Core not connected");
88
- return;
89
- }
90
- if (!zone) {
91
- reject("No active zone");
92
- return;
93
- }
94
- const browse = coreInstance.services.RoonApiBrowse;
95
- const sessionKey = `search_${Date.now()}`;
96
- const opts = {
97
- hierarchy: "search",
98
- input: query,
99
- multi_session_key: sessionKey,
100
- zone_or_output_id: zone.zone_id,
101
- };
102
- browse.browse(opts, (error, result) => {
103
- if (error) {
104
- reject(error);
105
- return;
106
- }
107
- browse.load(Object.assign(Object.assign({}, opts), { offset: 0, count: result.list.count }), (loadError, loadResult) => {
108
- var _a;
109
- if (loadError) {
110
- reject(loadError);
111
- return;
112
- }
113
- const tracksCategory = (_a = loadResult.items) === null || _a === void 0 ? void 0 : _a.find((item) => item.title === "Tracks");
114
- if (!tracksCategory) {
115
- // No tracks category found - return an empty array
116
- resolve([]);
117
- return;
118
- }
119
- browse.browse(Object.assign(Object.assign({}, opts), { item_key: tracksCategory.item_key }), (browseError, browseResult) => {
120
- if (browseError) {
121
- reject(browseError);
122
- return;
123
- }
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;
126
- if (tracksError) {
127
- reject(tracksError);
128
- return;
129
- }
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) => ({
135
- title: item.title || "Unknown",
136
- subtitle: item.subtitle || "",
137
- item_key: item.item_key,
138
- image_key: item.image_key,
139
- image: cachedImages.get(item.image_key) || null,
140
- hint: item.hint,
141
- sessionKey: sessionKey,
142
- }))) || [];
143
- resolve(items);
144
- }));
145
- });
146
- });
147
- });
148
- });
149
- }
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) {
184
- return new Promise((resolve, reject) => {
185
- if (!coreInstance) {
186
- reject("Roon Core not connected");
187
- return;
188
- }
189
- if (!zone) {
190
- reject("No active zone");
191
- return;
192
- }
193
- const browse = coreInstance.services.RoonApiBrowse;
194
- const actionTitles = ACTION_TITLES[action];
195
- function loadUntilAction(currentItemKey, depth = 0) {
196
- if (depth > 5) {
197
- reject("Too many levels, cannot find action");
198
- return;
199
- }
200
- browse.browse({
201
- hierarchy: "search",
202
- multi_session_key: sessionKey,
203
- item_key: currentItemKey,
204
- zone_or_output_id: zone.zone_id,
205
- }, (browseError, browseResult) => {
206
- var _a;
207
- if (browseError) {
208
- reject(browseError);
209
- return;
210
- }
211
- browse.load({
212
- hierarchy: "search",
213
- multi_session_key: sessionKey,
214
- item_key: currentItemKey,
215
- offset: 0,
216
- count: ((_a = browseResult.list) === null || _a === void 0 ? void 0 : _a.count) || 10,
217
- zone_or_output_id: zone.zone_id,
218
- }, (loadError, loadResult) => {
219
- if (loadError) {
220
- reject(loadError);
221
- return;
222
- }
223
- if (!loadResult.items || loadResult.items.length === 0) {
224
- reject("No items found");
225
- return;
226
- }
227
- const targetAction = loadResult.items.find((item) => item.hint === "action" &&
228
- actionTitles.some((title) => item.title === title));
229
- if (targetAction) {
230
- browse.browse({
231
- hierarchy: "search",
232
- multi_session_key: sessionKey,
233
- item_key: targetAction.item_key,
234
- zone_or_output_id: zone.zone_id,
235
- }, (playError) => {
236
- if (playError) {
237
- reject(playError);
238
- }
239
- else {
240
- console.log(`Successfully executed action: ${action}`);
241
- resolve();
242
- }
243
- });
244
- }
245
- else {
246
- const actionList = loadResult.items.find((item) => item.hint === "action_list");
247
- if (actionList) {
248
- loadUntilAction(actionList.item_key, depth + 1);
249
- }
250
- else {
251
- reject(`Could not find ${action} action or next level`);
252
- }
253
- }
254
- });
255
- });
256
- }
257
- loadUntilAction(itemKey);
258
- });
259
- }
260
- function getZone() {
261
- return zone;
262
- }
263
- function getCore() {
264
- return coreInstance;
265
- }
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=exports,n={get getCore(){return h},get getZone(){return g},get initRoon(){return c},get parseNowPlaying(){return d},get playItem(){return y},get searchRoon(){return p}};for(var i in n)Object.defineProperty(e,i,{enumerable:!0,get:Object.getOwnPropertyDescriptor(n,i).get});let o=/*#__PURE__*/l(require("node-roon-api")),t=/*#__PURE__*/l(require("node-roon-api-browse")),r=/*#__PURE__*/l(require("node-roon-api-image")),s=/*#__PURE__*/l(require("node-roon-api-transport")),a=require("./image-cache");function l(e){return e&&e.__esModule?e:{default:e}}let u=null,_=null,d=e=>{let n=e.three_line?.line1||"Unknown Track";return{title:n,artists:e.three_line?.line2?[e.three_line.line2.split(" / ").map(e=>e.trim())[0]]:["Unknown Artist"],album:e.three_line?.line3||""}};function c(e){let n=new o.default({extension_id:"com.bluemancz.roonpipe",display_name:"RoonPipe",display_version:"1.0.4",publisher:"BlueManCZ",email:"your@email.com",website:"https://github.com/bluemancz/roonpipe",log_level:"none",core_paired:n=>{_=n,n.services.RoonApiTransport.subscribe_zones((i,o)=>{if("Subscribed"===i)u=o.zones.find(e=>"playing"===e.state)||o.zones[0],e.onZoneChanged(u,n);else if("Changed"===i){if(o.zones_changed){let i=o.zones_changed.find(e=>"playing"===e.state);i?u=i:u&&(u=o.zones_changed.find(e=>e.zone_id===u.zone_id)||u),e.onZoneChanged(u,n)}if(o.zones_seek_changed){let n=o.zones_seek_changed.find(e=>e.zone_id===u?.zone_id);n&&u?.now_playing&&(u.now_playing.seek_position=n.seek_position,e.onSeekChanged(1e6*n.seek_position))}}}),e.onCorePaired(n),console.log(`Core paired: ${n.display_name}`)},core_unpaired:n=>{u=null,_=null,e.onCoreUnpaired(n),console.log(`Core unpaired: ${n.display_name}`)}});n.init_services({required_services:[t.default,r.default,s.default]}),n.start_discovery()}function p(e){return new Promise((n,i)=>{if(!_)return void i("Roon Core not connected");if(!u)return void i("No active zone");let o=_.services.RoonApiBrowse,t=`search_${Date.now()}`,r={hierarchy:"search",input:e,multi_session_key:t,zone_or_output_id:u.zone_id};o.browse(r,(e,s)=>{e?i(e):o.load({...r,offset:0,count:s.list.count},(e,s)=>{if(e)return void i(e);let l=s.items?.find(e=>"Tracks"===e.title);l?o.browse({...r,item_key:l.item_key},(e,s)=>{e?i(e):o.load({...r,item_key:l.item_key,offset:0,count:Math.min(s.list.count,50)},async(e,o)=>{if(e)return void i(e);let r=o.items?.map(e=>e.image_key).filter(Boolean)||[],s=_.services.RoonApiImage,l=await (0,a.cacheImages)(s,r);n(o.items?.map(e=>({title:e.title||"Unknown Track",subtitle:e.subtitle?e.subtitle.split(", ")[0]:"Unknown Artist",item_key:e.item_key,image:l.get(e.image_key)||null,hint:e.hint,sessionKey:t}))||[])})}):n([])})})})}let m={play:["Play Now","Play"],queue:["Queue","Add to Queue"],addNext:["Play From Here","Add Next"]};async function y(e,n,i="play"){return"playNow"===i?void(u&&u.now_playing&&("playing"===u.state||"paused"===u.state)?(await f(e,n,"addNext"),await new Promise((e,n)=>{_&&u?_.services.RoonApiTransport.control(u,"next",i=>{i?n(i):e()}):n("Not connected")})):await f(e,n,"play")):f(e,n,i)}function f(e,n,i){return new Promise((o,t)=>{if(!_)return void t("Roon Core not connected");if(!u)return void t("No active zone");let r=_.services.RoonApiBrowse,s=m[i];!function e(a,l=0){l>5?t("Too many levels, cannot find action"):r.browse({hierarchy:"search",multi_session_key:n,item_key:a,zone_or_output_id:u.zone_id},(_,d)=>{_?t(_):r.load({hierarchy:"search",multi_session_key:n,item_key:a,offset:0,count:d.list?.count||10,zone_or_output_id:u.zone_id},(a,_)=>{if(a)return void t(a);if(!_.items||0===_.items.length)return void t("No items found");let d=_.items.find(e=>"action"===e.hint&&s.some(n=>e.title===n));if(d)r.browse({hierarchy:"search",multi_session_key:n,item_key:d.item_key,zone_or_output_id:u.zone_id},e=>{e?t(e):(console.log(`Successfully executed action: ${i}`),o())});else{let n=_.items.find(e=>"action_list"===e.hint);n?e(n.item_key,l+1):t(`Could not find ${i} action or next level`)}})})}(e)})}function g(){return u}function h(){return _}
package/dist/socket.js CHANGED
@@ -1,97 +1,7 @@
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.isInstanceRunning = isInstanceRunning;
16
- exports.startSocketServer = startSocketServer;
17
- const node_fs_1 = __importDefault(require("node:fs"));
18
- const node_net_1 = __importDefault(require("node:net"));
19
- const SOCKET_PATH = "/tmp/roonpipe.sock";
20
- let socketServer = null;
21
- /**
22
- * Check if another instance is already running by trying to connect to the socket
23
- */
24
- function isInstanceRunning() {
25
- return new Promise((resolve) => {
26
- if (!node_fs_1.default.existsSync(SOCKET_PATH)) {
27
- resolve(false);
28
- return;
29
- }
30
- const client = node_net_1.default.createConnection({ path: SOCKET_PATH }, () => {
31
- // Connection successful - another instance is running
32
- client.end();
33
- resolve(true);
34
- });
35
- client.on("error", () => {
36
- // Connection failed - socket is stale, no instance running
37
- resolve(false);
38
- });
39
- // Timeout after 1 second
40
- client.setTimeout(1000, () => {
41
- client.destroy();
42
- resolve(false);
43
- });
44
- });
45
- }
46
- function startSocketServer(handlers) {
47
- // Remove old socket if exists
48
- if (node_fs_1.default.existsSync(SOCKET_PATH)) {
49
- node_fs_1.default.unlinkSync(SOCKET_PATH);
50
- }
51
- socketServer = node_net_1.default.createServer((client) => {
52
- console.log("Client connected to socket");
53
- client.on("data", (data) => __awaiter(this, void 0, void 0, function* () {
54
- try {
55
- const request = JSON.parse(data.toString());
56
- console.log("Received request:", request);
57
- if (request.command === "search") {
58
- try {
59
- const results = yield handlers.search(request.query);
60
- client.write(`${JSON.stringify({ error: null, results })}\n`);
61
- }
62
- catch (error) {
63
- client.write(`${JSON.stringify({ error: String(error), results: null })}\n`);
64
- }
65
- client.end();
66
- }
67
- else if (request.command === "play") {
68
- try {
69
- yield handlers.play(request.item_key, request.session_key, request.action);
70
- client.write(`${JSON.stringify({ error: null, success: true })}\n`);
71
- }
72
- catch (error) {
73
- client.write(`${JSON.stringify({ error: String(error), success: false })}\n`);
74
- }
75
- client.end();
76
- }
77
- else {
78
- client.write(`${JSON.stringify({ error: "Unknown command" })}\n`);
79
- client.end();
80
- }
81
- }
82
- catch (e) {
83
- console.error("Socket error:", e);
84
- client.write(`${JSON.stringify({ error: "Invalid request format" })}\n`);
85
- client.end();
86
- }
87
- }));
88
- client.on("error", (err) => {
89
- console.error("Client error:", err);
90
- });
91
- });
92
- socketServer.listen(SOCKET_PATH, () => {
93
- console.log("Unix socket server listening on", SOCKET_PATH);
94
- // Set permissions so any user can connect
95
- node_fs_1.default.chmodSync(SOCKET_PATH, 0o666);
96
- });
97
- }
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=exports,r={get isInstanceRunning(){return c},get startSocketServer(){return l}};for(var t in r)Object.defineProperty(e,t,{enumerable:!0,get:Object.getOwnPropertyDescriptor(r,t).get});let n=/*#__PURE__*/i(require("node:fs")),o=/*#__PURE__*/i(require("node:net"));function i(e){return e&&e.__esModule?e:{default:e}}let s="/tmp/roonpipe.sock";function c(){return new Promise(e=>{if(!n.default.existsSync(s))return void e(!1);let r=o.default.createConnection({path:s},()=>{r.end(),e(!0)});r.on("error",()=>{e(!1)}),r.setTimeout(1e3,()=>{r.destroy(),e(!1)})})}function l(e){n.default.existsSync(s)&&n.default.unlinkSync(s),o.default.createServer(r=>{console.log("Client connected to socket"),r.on("data",async t=>{try{let n=JSON.parse(t.toString());if(console.log("Received request:",n),"search"===n.command){try{let t=await e.search(n.query);r.write(`${JSON.stringify({error:null,results:t})}
2
+ `)}catch(e){r.write(`${JSON.stringify({error:String(e),results:null})}
3
+ `)}r.end()}else if("play"===n.command){try{await e.play(n.item_key,n.session_key,n.action),r.write(`${JSON.stringify({error:null,success:!0})}
4
+ `)}catch(e){r.write(`${JSON.stringify({error:String(e),success:!1})}
5
+ `)}r.end()}else r.write(`${JSON.stringify({error:"Unknown command"})}
6
+ `),r.end()}catch(e){console.error("Socket error:",e),r.write(`${JSON.stringify({error:"Invalid request format"})}
7
+ `),r.end()}}),r.on("error",e=>{console.error("Client error:",e)})}).listen(s,()=>{console.log("Unix socket server listening on",s),n.default.chmodSync(s,438)})}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roonpipe",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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": {
@@ -22,28 +22,35 @@
22
22
  },
23
23
  "devDependencies": {
24
24
  "@biomejs/biome": "^2.3.11",
25
- "@types/node-notifier": "^8.0.5",
25
+ "@swc/cli": "^0.7.10",
26
+ "@swc/core": "^1.15.10",
27
+ "@swc/register": "^0.1.10",
28
+ "@types/node": "^25.0.9",
26
29
  "nodemon": "^3.1.11",
27
- "ts-node": "^10.9.2",
28
30
  "typescript": "^5.9.3"
29
31
  },
30
32
  "files": [
31
- "dist"
33
+ "dist",
34
+ "scripts",
35
+ "data",
36
+ "!scripts/postbuild.ts"
32
37
  ],
33
38
  "dependencies": {
34
39
  "@inquirer/prompts": "^8.2.0",
40
+ "dbus-next": "^0.10.2",
35
41
  "mpris-service": "^2.1.2",
36
- "node-notifier": "^10.0.1",
37
42
  "node-roon-api": "github:roonlabs/node-roon-api",
38
43
  "node-roon-api-browse": "github:roonlabs/node-roon-api-browse",
39
44
  "node-roon-api-image": "github:roonlabs/node-roon-api-image",
40
45
  "node-roon-api-transport": "github:roonlabs/node-roon-api-transport"
41
46
  },
42
47
  "scripts": {
43
- "build": "tsc",
48
+ "build": "swc src -d dist --strip-leading-paths",
49
+ "postbuild": "node --require @swc/register scripts/postbuild.ts",
50
+ "postinstall": "bash scripts/install-gnome-search-provider.sh",
44
51
  "start": "node dist/index.js",
45
52
  "dev": "nodemon src/index.ts",
46
- "cli": "tsc && node dist/index.js --cli",
53
+ "cli": "pnpm build && node dist/index.js --cli",
47
54
  "biome": "biome check"
48
55
  }
49
56
  }
@@ -0,0 +1,37 @@
1
+ #!/bin/bash
2
+
3
+ # Install GNOME Shell Search Provider for RoonPipe
4
+
5
+ # Check if running as root
6
+ if [ "$EUID" -ne 0 ]; then
7
+ echo "This script must be run as root to install system-wide."
8
+ echo "Try: sudo $0"
9
+ exit 1
10
+ fi
11
+
12
+ DESKTOP_FILE="com.bluemancz.RoonPipe.desktop"
13
+ SEARCH_PROVIDER_FILE="com.bluemancz.RoonPipe.SearchProvider.ini"
14
+ SERVICE_FILE="com.bluemancz.RoonPipe.SearchProvider.service"
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ DATA_DIR="$SCRIPT_DIR/../data"
18
+
19
+ # Install desktop file system-wide
20
+ cp "$DATA_DIR/$DESKTOP_FILE" "/usr/share/applications/"
21
+ echo "Installed: /usr/share/applications/$DESKTOP_FILE"
22
+
23
+ # Install search provider system-wide
24
+ mkdir -p "/usr/share/gnome-shell/search-providers"
25
+ cp "$DATA_DIR/$SEARCH_PROVIDER_FILE" "/usr/share/gnome-shell/search-providers/"
26
+ echo "Installed: /usr/share/gnome-shell/search-providers/$SEARCH_PROVIDER_FILE"
27
+
28
+ # Install DBus service system-wide
29
+ mkdir -p "/usr/share/dbus-1/services"
30
+ cp "$DATA_DIR/$SERVICE_FILE" "/usr/share/dbus-1/services/"
31
+ echo "Installed: /usr/share/dbus-1/services/$SERVICE_FILE"
32
+
33
+ echo ""
34
+ echo "GNOME Shell Search Provider installed successfully!"
35
+ echo "You may need to restart GNOME Shell (Alt+F2, r) for changes to take effect."
36
+ echo ""
37
+ echo "Make sure RoonPipe daemon is running for search to work."