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 +21 -0
- package/README.md +174 -0
- package/dist/cli.js +152 -0
- package/dist/index.js +48 -0
- package/dist/mpris.js +215 -0
- package/dist/roon.js +216 -0
- package/dist/socket.js +97 -0
- package/package.json +48 -0
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
|
+

|
|
6
|
+

|
|
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
|
+
}
|