roonpipe 1.0.0

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/LICENCE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivo Šmerek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # RoonPipe
2
+
3
+ A Linux integration layer for [Roon](https://roonlabs.com/) that brings native desktop features like MPRIS support, media key controls, desktop notifications, and a powerful search CLI.
4
+
5
+ ![License](https://img.shields.io/badge/license-MIT-blue.svg)
6
+ ![Platform](https://img.shields.io/badge/platform-Linux-lightgrey.svg)
7
+
8
+ ## Features
9
+
10
+ - **MPRIS Integration** — Control Roon playback using standard Linux media keys, `playerctl`, or any MPRIS-compatible application
11
+ - **Desktop Notifications** — Get notified when tracks change, complete with album artwork
12
+ - **Playback Controls** — Play, pause, stop, skip, seek, volume, shuffle, and loop
13
+ - **Track Search** — Search your entire Roon library (including TIDAL/Qobuz) via CLI or programmatically
14
+ - **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
+
17
+ ## CLI Example
18
+
19
+ ```
20
+ 🎵 RoonPipe Interactive Search
21
+ ==============================
22
+
23
+ 🔍 Search for a track: pink floyd
24
+
25
+ Searching for "pink floyd"...
26
+
27
+ Found 50 track(s):
28
+
29
+ ❯ Comfortably Numb · Pink Floyd · The Wall
30
+ Wish You Were Here · Pink Floyd · Wish You Were Here
31
+ Time · Pink Floyd · The Dark Side of the Moon
32
+ Another Brick in the Wall, Pt. 2 · Pink Floyd · The Wall
33
+ ────────────────────────────────────────
34
+ 🔍 New search
35
+ ❌ Quit
36
+ ```
37
+
38
+ ## Requirements
39
+
40
+ - Linux with D-Bus (for MPRIS)
41
+ - Node.js 18+
42
+ - Roon Core on your network
43
+ - `libnotify` for desktop notifications (optional)
44
+
45
+ ## Installation
46
+
47
+ ### From npm (recommended)
48
+
49
+ ```bash
50
+ npm install -g roonpipe
51
+ ```
52
+
53
+ ### From source
54
+
55
+ ```bash
56
+ git clone https://github.com/BlueManCZ/roonpipe.git
57
+ cd roonpipe
58
+ pnpm install
59
+ pnpm build
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ### Running the Daemon
65
+
66
+ Start the daemon to enable MPRIS integration and the socket server:
67
+
68
+ ```bash
69
+ roonpipe
70
+ ```
71
+
72
+ Or if installed from source:
73
+
74
+ ```bash
75
+ pnpm start
76
+ ```
77
+
78
+ On first run, open Roon and authorize the "RoonPipe" extension in **Settings → Extensions**.
79
+
80
+ ### Interactive CLI
81
+
82
+ Search and play tracks from your terminal:
83
+
84
+ ```bash
85
+ roonpipe --cli
86
+ ```
87
+
88
+ Or if installed from source:
89
+
90
+ ```bash
91
+ pnpm run cli
92
+ ```
93
+
94
+ Use arrow keys to navigate results and press Enter to play.
95
+
96
+ ### Development Mode
97
+
98
+ Run with hot-reload during development:
99
+
100
+ ```bash
101
+ pnpm dev
102
+ ```
103
+
104
+ ## MPRIS Controls
105
+
106
+ Once the daemon is running, you can control Roon using standard tools:
107
+
108
+ ```bash
109
+ # Basic controls
110
+ playerctl -p roon play
111
+ playerctl -p roon pause
112
+ playerctl -p roon next
113
+ playerctl -p roon previous
114
+
115
+ # Volume
116
+ playerctl -p roon volume 0.5
117
+
118
+ # Seek (in seconds)
119
+ playerctl -p roon position 30
120
+
121
+ # Get current track info
122
+ playerctl -p roon metadata
123
+ ```
124
+
125
+ ## Socket API
126
+
127
+ RoonPipe exposes a Unix socket at `/tmp/roonpipe.sock` for IPC communication.
128
+
129
+ ### Search
130
+
131
+ ```bash
132
+ echo '{"command":"search","query":"beatles"}' | nc -U /tmp/roonpipe.sock
133
+ ```
134
+
135
+ Response:
136
+ ```json
137
+ {
138
+ "results": [
139
+ {
140
+ "title": "Let It Be",
141
+ "subtitle": "The Beatles · Let It Be",
142
+ "item_key": "10:0",
143
+ "sessionKey": "search_1234567890"
144
+ }
145
+ ]
146
+ }
147
+ ```
148
+
149
+ ### Play
150
+
151
+ ```bash
152
+ echo '{"command":"play","item_key":"10:0","session_key":"search_1234567890"}' | nc -U /tmp/roonpipe.sock
153
+ ```
154
+
155
+ ## Project Structure
156
+
157
+ ```
158
+ 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
164
+ ```
165
+
166
+ ## Contributing
167
+
168
+ Contributions are welcome! Feel free to open issues or submit pull requests.
169
+
170
+ ## Acknowledgments
171
+
172
+ - [Roon Labs](https://roonlabs.com/) for the amazing music player and API
173
+ - [node-roon-api](https://github.com/roonlabs/node-roon-api) — Official Roon Node.js API
174
+ - [mpris-service](https://github.com/dbusjs/mpris-service) — MPRIS implementation for Node.js
package/dist/cli.js ADDED
@@ -0,0 +1,152 @@
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 playTrack(track) {
113
+ return __awaiter(this, void 0, void 0, function* () {
114
+ console.log(`\n▶️ Playing: ${track.title}${track.subtitle ? ` · ${track.subtitle}` : ""}\n`);
115
+ try {
116
+ yield sendCommand({
117
+ command: "play",
118
+ item_key: track.item_key,
119
+ session_key: track.sessionKey,
120
+ });
121
+ console.log("✅ Playback started!\n");
122
+ }
123
+ catch (error) {
124
+ console.error("❌ Failed to play track:", error);
125
+ }
126
+ });
127
+ }
128
+ function startCLI() {
129
+ return __awaiter(this, void 0, void 0, function* () {
130
+ console.log("\n🎵 RoonPipe Interactive Search");
131
+ console.log("==============================\n");
132
+ let running = true;
133
+ while (running) {
134
+ const results = yield search();
135
+ if (results.length === 0) {
136
+ console.log("❌ No tracks found.\n");
137
+ continue;
138
+ }
139
+ console.log(`Found ${results.length} track(s):\n`);
140
+ const selected = yield selectTrack(results);
141
+ if (selected === null) {
142
+ console.log("\nGoodbye! 👋\n");
143
+ running = false;
144
+ continue;
145
+ }
146
+ if (selected.subtitle === "__search__") {
147
+ continue;
148
+ }
149
+ yield playTrack(selected);
150
+ }
151
+ });
152
+ }
package/dist/index.js ADDED
@@ -0,0 +1,48 @@
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
+ }
package/dist/mpris.js ADDED
@@ -0,0 +1,215 @@
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
+ }
package/dist/roon.js ADDED
@@ -0,0 +1,216 @@
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.initRoon = initRoon;
7
+ exports.searchRoon = searchRoon;
8
+ exports.playItem = playItem;
9
+ exports.getZone = getZone;
10
+ exports.getCore = getCore;
11
+ // @ts-expect-error
12
+ const node_roon_api_1 = __importDefault(require("node-roon-api"));
13
+ // @ts-expect-error
14
+ const node_roon_api_browse_1 = __importDefault(require("node-roon-api-browse"));
15
+ // @ts-expect-error
16
+ const node_roon_api_image_1 = __importDefault(require("node-roon-api-image"));
17
+ // @ts-expect-error
18
+ const node_roon_api_transport_1 = __importDefault(require("node-roon-api-transport"));
19
+ let zone = null;
20
+ let coreInstance = null;
21
+ function initRoon(callbacks) {
22
+ const roon = new node_roon_api_1.default({
23
+ extension_id: "com.bluemancz.roonpipe",
24
+ display_name: "RoonPipe",
25
+ display_version: "1.0.0",
26
+ publisher: "BlueManCZ",
27
+ email: "your@email.com",
28
+ website: "https://github.com/bluemancz/roonpipe",
29
+ core_paired: (core) => {
30
+ coreInstance = core;
31
+ const transport = core.services.RoonApiTransport;
32
+ transport.subscribe_zones((cmd, data) => {
33
+ if (cmd === "Subscribed") {
34
+ zone = data.zones.find((z) => z.state === "playing") || data.zones[0];
35
+ callbacks.onZoneChanged(zone, core);
36
+ }
37
+ else if (cmd === "Changed") {
38
+ if (data.zones_changed) {
39
+ const playingZone = data.zones_changed.find((z) => z.state === "playing");
40
+ if (playingZone) {
41
+ zone = playingZone;
42
+ }
43
+ else if (zone) {
44
+ zone =
45
+ data.zones_changed.find((z) => z.zone_id === zone.zone_id) ||
46
+ zone;
47
+ }
48
+ callbacks.onZoneChanged(zone, core);
49
+ }
50
+ if (data.zones_seek_changed) {
51
+ const seekUpdate = data.zones_seek_changed.find((z) => z.zone_id === (zone === null || zone === void 0 ? void 0 : zone.zone_id));
52
+ if (seekUpdate && zone) {
53
+ zone.now_playing.seek_position = seekUpdate.seek_position;
54
+ callbacks.onSeekChanged(seekUpdate.seek_position * 1000000);
55
+ }
56
+ }
57
+ }
58
+ });
59
+ callbacks.onCorePaired(core);
60
+ console.log(`Core paired: ${core.display_name}`);
61
+ },
62
+ core_unpaired: (core) => {
63
+ zone = null;
64
+ coreInstance = null;
65
+ callbacks.onCoreUnpaired(core);
66
+ console.log(`Core unpaired: ${core.display_name}`);
67
+ },
68
+ });
69
+ roon.init_services({
70
+ required_services: [node_roon_api_browse_1.default, node_roon_api_image_1.default, node_roon_api_transport_1.default],
71
+ });
72
+ roon.start_discovery();
73
+ }
74
+ function searchRoon(query) {
75
+ return new Promise((resolve, reject) => {
76
+ if (!coreInstance) {
77
+ reject("Roon Core not connected");
78
+ return;
79
+ }
80
+ if (!zone) {
81
+ reject("No active zone");
82
+ return;
83
+ }
84
+ const browse = coreInstance.services.RoonApiBrowse;
85
+ const sessionKey = `search_${Date.now()}`;
86
+ const opts = {
87
+ hierarchy: "search",
88
+ input: query,
89
+ multi_session_key: sessionKey,
90
+ zone_or_output_id: zone.zone_id,
91
+ };
92
+ browse.browse(opts, (error, result) => {
93
+ if (error) {
94
+ reject(error);
95
+ return;
96
+ }
97
+ browse.load(Object.assign(Object.assign({}, opts), { offset: 0, count: result.list.count }), (loadError, loadResult) => {
98
+ var _a;
99
+ if (loadError) {
100
+ reject(loadError);
101
+ return;
102
+ }
103
+ const tracksCategory = (_a = loadResult.items) === null || _a === void 0 ? void 0 : _a.find((item) => item.title === "Tracks");
104
+ if (!tracksCategory) {
105
+ // No tracks category found - return empty array
106
+ resolve([]);
107
+ return;
108
+ }
109
+ browse.browse(Object.assign(Object.assign({}, opts), { item_key: tracksCategory.item_key }), (browseError, browseResult) => {
110
+ if (browseError) {
111
+ reject(browseError);
112
+ return;
113
+ }
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;
116
+ if (tracksError) {
117
+ reject(tracksError);
118
+ return;
119
+ }
120
+ const items = ((_a = tracksResult.items) === null || _a === void 0 ? void 0 : _a.map((item) => ({
121
+ title: item.title || "Unknown",
122
+ subtitle: item.subtitle || "",
123
+ item_key: item.item_key,
124
+ image_key: item.image_key,
125
+ hint: item.hint,
126
+ sessionKey: sessionKey,
127
+ }))) || [];
128
+ resolve(items);
129
+ });
130
+ });
131
+ });
132
+ });
133
+ });
134
+ }
135
+ function playItem(itemKey, sessionKey) {
136
+ return new Promise((resolve, reject) => {
137
+ if (!coreInstance) {
138
+ reject("Roon Core not connected");
139
+ return;
140
+ }
141
+ if (!zone) {
142
+ reject("No active zone");
143
+ return;
144
+ }
145
+ const browse = coreInstance.services.RoonApiBrowse;
146
+ function loadUntilAction(currentItemKey, depth = 0) {
147
+ if (depth > 5) {
148
+ reject("Too many levels, cannot find action");
149
+ return;
150
+ }
151
+ browse.browse({
152
+ hierarchy: "search",
153
+ multi_session_key: sessionKey,
154
+ item_key: currentItemKey,
155
+ zone_or_output_id: zone.zone_id,
156
+ }, (browseError, browseResult) => {
157
+ var _a;
158
+ if (browseError) {
159
+ reject(browseError);
160
+ return;
161
+ }
162
+ browse.load({
163
+ hierarchy: "search",
164
+ multi_session_key: sessionKey,
165
+ item_key: currentItemKey,
166
+ offset: 0,
167
+ count: ((_a = browseResult.list) === null || _a === void 0 ? void 0 : _a.count) || 10,
168
+ zone_or_output_id: zone.zone_id,
169
+ }, (loadError, loadResult) => {
170
+ if (loadError) {
171
+ reject(loadError);
172
+ return;
173
+ }
174
+ if (!loadResult.items || loadResult.items.length === 0) {
175
+ reject("No items found");
176
+ return;
177
+ }
178
+ const playNowAction = loadResult.items.find((item) => item.hint === "action" &&
179
+ (item.title === "Play Now" || item.title === "Play"));
180
+ if (playNowAction) {
181
+ browse.browse({
182
+ hierarchy: "search",
183
+ multi_session_key: sessionKey,
184
+ item_key: playNowAction.item_key,
185
+ zone_or_output_id: zone.zone_id,
186
+ }, (playError) => {
187
+ if (playError) {
188
+ reject(playError);
189
+ }
190
+ else {
191
+ console.log("Successfully started playback");
192
+ resolve();
193
+ }
194
+ });
195
+ }
196
+ else {
197
+ const actionList = loadResult.items.find((item) => item.hint === "action_list");
198
+ if (actionList) {
199
+ loadUntilAction(actionList.item_key, depth + 1);
200
+ }
201
+ else {
202
+ reject("Could not find Play action or next level");
203
+ }
204
+ }
205
+ });
206
+ });
207
+ }
208
+ loadUntilAction(itemKey);
209
+ });
210
+ }
211
+ function getZone() {
212
+ return zone;
213
+ }
214
+ function getCore() {
215
+ return coreInstance;
216
+ }
package/dist/socket.js ADDED
@@ -0,0 +1,97 @@
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);
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
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "roonpipe",
3
+ "version": "1.0.0",
4
+ "description": "Linux integration for Roon – MPRIS support, media keys, desktop notifications, and interactive search CLI",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "roonpipe": "dist/index.js"
8
+ },
9
+ "keywords": [
10
+ "roon",
11
+ "mpris",
12
+ "linux",
13
+ "media-player",
14
+ "tidal",
15
+ "qobuz"
16
+ ],
17
+ "author": "Ivo Šmerek",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/BlueManCZ/roonpipe.git"
22
+ },
23
+ "devDependencies": {
24
+ "@biomejs/biome": "^2.3.11",
25
+ "@types/node-notifier": "^8.0.5",
26
+ "nodemon": "^3.1.11",
27
+ "ts-node": "^10.9.2",
28
+ "typescript": "^5.5.3"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "dependencies": {
34
+ "@inquirer/prompts": "^8.2.0",
35
+ "mpris-service": "^2.1.2",
36
+ "node-notifier": "^10.0.1",
37
+ "node-roon-api": "github:roonlabs/node-roon-api",
38
+ "node-roon-api-browse": "github:roonlabs/node-roon-api-browse",
39
+ "node-roon-api-image": "github:roonlabs/node-roon-api-image",
40
+ "node-roon-api-transport": "github:roonlabs/node-roon-api-transport"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc",
44
+ "start": "node dist/index.js",
45
+ "dev": "nodemon src/index.ts",
46
+ "cli": "tsc && node dist/index.js --cli"
47
+ }
48
+ }