diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..c88ab18 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,30 @@ +name: Validate + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install test dependencies + run: sudo apt-get update && sudo apt-get install -y jq socat + - name: Validate manifest contract + run: | + jq -e ' + .schemaVersion == 1 + and .id == "getsubwave.radio" + and (.kinds | sort) == (["bar-widget", "overlay"] | sort) + and .entryPoints.barWidget == "BarWidget.qml" + and .entryPoints.overlay == "StationPicker.qml" + ' manifest.json + test -x subwave-fetch + test -x subwave-player + test -x tests/run + - name: Run tests + run: ./tests/run diff --git a/BarWidget.qml b/BarWidget.qml new file mode 100644 index 0000000..16010cb --- /dev/null +++ b/BarWidget.qml @@ -0,0 +1,167 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui +import "StationModel.js" as StationModel + +BarWidget { + id: root + moduleName: "getsubwave.radio" + + property bool playerRunning: false + property bool playerPaused: false + property int playerVolume: 70 + property string stationName: "SUB/WAVE" + property string stationUrl: "" + property string trackTitle: "" + property string trackArtist: "" + property bool stationOnline: true + readonly property string playerPath: Qt.resolvedUrl("subwave-player").toString().replace(/^file:\/\//, "") + readonly property string fetchPath: Qt.resolvedUrl("subwave-fetch").toString().replace(/^file:\/\//, "") + readonly property string statusPath: Quickshell.env("XDG_RUNTIME_DIR") + "/omarchy-subwave/status.json" + readonly property string label: trackTitle + ? trackTitle + (trackArtist ? " · " + trackArtist : "") + : stationName + + function applyStatus(raw) { + try { + if (typeof raw !== "string" || raw.length > 65536) return + var state = JSON.parse(raw || "{}") + playerRunning = state.running === true + playerPaused = state.paused === true + playerVolume = Math.max(0, Math.min(100, Math.round(Number(state.volume || 70)))) + stationName = StationModel.singleLine(state.station && state.station.name || "SUB/WAVE", 160) + stationUrl = StationModel.normalizeOrigin(state.station && state.station.url || "") + if (!playerRunning) { + trackTitle = "" + trackArtist = "" + } + nowTimer.running = playerRunning && stationUrl !== "" + if (nowTimer.running) refreshNowPlaying() + } catch (error) {} + } + + function applyNowPlaying(raw) { + try { + if (typeof raw !== "string" || raw.length > 65536) return + var state = JSON.parse(raw || "{}") + stationOnline = state.online !== false + if (state.station) stationName = StationModel.singleLine(state.station, 160) + if (!state.error) { + trackTitle = StationModel.singleLine(state.title, 512) + trackArtist = StationModel.singleLine(state.artist, 512) + } + } catch (error) { stationOnline = false } + } + + function runAction(action, value) { + if (actionProcess.running) return + actionProcess.command = value === undefined + ? [playerPath, action] + : [playerPath, action, String(value)] + actionProcess.running = true + } + + function changeVolume(delta) { + runAction("volume", Math.max(0, Math.min(100, playerVolume + (delta > 0 ? 5 : -5)))) + } + + function toggleOverlay() { + var configured = StationModel.normalizeOrigin(setting("stationUrl", "")) + var payload = JSON.stringify({ stationUrl: configured }) + Quickshell.execDetached(["omarchy-shell", "shell", "toggle", "getsubwave.radio", payload]) + } + + function refreshNowPlaying() { + if (nowProcess.running || !stationUrl) return + nowProcess.command = [fetchPath, "now-playing", stationUrl] + nowProcess.running = true + } + + implicitWidth: row.implicitWidth + Style.space(14) + implicitHeight: barSize + + FileView { + path: root.statusPath + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: root.applyStatus(text()) + onFileChanged: reload() + } + + Process { + id: statusProcess + command: [root.playerPath, "status"] + running: true + stdout: StdioCollector { id: statusOutput; waitForEnd: true } + onExited: root.applyStatus(statusOutput.text) + } + + Process { + id: actionProcess + command: [] + stdout: StdioCollector { id: actionOutput; waitForEnd: true } + onExited: root.applyStatus(actionOutput.text) + } + + Process { + id: nowProcess + command: [] + stdout: StdioCollector { id: nowOutput; waitForEnd: true } + onExited: root.applyNowPlaying(nowOutput.text) + } + + Timer { + id: nowTimer + interval: 5000 + repeat: true + running: false + onTriggered: root.refreshNowPlaying() + } + + Row { + id: row + anchors.centerIn: parent + spacing: Style.space(6) + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "󰝚" + color: root.playerRunning && !root.playerPaused && root.stationOnline + ? Color.accent : root.bar.barForeground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.body + } + + Text { + visible: !root.vertical + width: Math.min(implicitWidth, Style.space(180)) + anchors.verticalCenter: parent.verticalCenter + text: root.label + color: root.bar.barForeground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: function(mouse) { + if (mouse.button === Qt.MiddleButton) root.runAction("toggle") + else if (mouse.button === Qt.RightButton) root.runAction("stop") + else root.toggleOverlay() + } + onWheel: function(wheel) { root.changeVolume(wheel.angleDelta.y) } + onEntered: if (root.bar) root.bar.showTooltip(root, + (root.playerRunning ? (root.playerPaused ? "Paused · " : "Playing · ") : "Open · ") + + root.stationName + (root.trackTitle ? "\n" + root.label : "") + + " · " + root.playerVolume + "%") + onExited: if (root.bar) root.bar.hideTooltip(root) + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fa079b3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SUB/WAVE contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..61b2698 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# SUB/WAVE for Omarchy + +Listen to your own [SUB/WAVE](https://www.getsubwave.com) station and explore +the public community directory without leaving the Omarchy shell. The plugin +adds a theme-aware bar widget, a searchable station picker, and an MPV player +that works with Omarchy's existing media controls. + +![SUB/WAVE station picker in Omarchy](preview.png) + +## Install + +```bash +omarchy plugin add https://github.com/getsubwave/omarchy-subwave.git --enable +``` + +The community directory works immediately. To put your own self-hosted station +first in the list, configure its bare public origin: + +```bash +omarchy bar set getsubwave.radio stationUrl https://radio.example.com +``` + +Do not include `/listen`, `/stream.mp3`, credentials, query parameters, or a +fragment. The plugin derives the API and stream paths from the origin. + +## Controls + +### Bar + +| Input | Action | +| --- | --- | +| Left click | Open or close the station picker | +| Middle click | Play or pause | +| Right click | Stop playback | +| Mouse wheel | Change volume in 5% steps | + +### Station picker + +| Input | Action | +| --- | --- | +| Type | Search names, places, genres, operators, and descriptions | +| Up / Down | Move through stations | +| Enter | Play the selected station | +| Escape | Clear search, then close | + +Selecting the station that is already playing closes the picker. Playback uses +the station's always-available `/stream.mp3` mount. `mpv-mpris` exposes it to +the built-in `omarchy.media` widget, media keys, and compatible headset controls. + +## Data and privacy + +The plugin fetches the normalized community directory from +`https://www.getsubwave.com/stations.json`. It asks each visible station's +public `/api/now-playing` endpoint for its current track and online state, and +connects MPV directly to the selected station's `/stream.mp3` mount. + +Remote responses are size- and record-limited before entering QML. Only +credential-free HTTP(S) origins are accepted, remote strings are never +evaluated as commands, and cache/status writes are atomic. + +Version 1 supports public stations only. It does not accept or store listener +passwords. Favorites, requests, and station administration remain in the +station's web player. + +Non-secret persistent data is stored in: + +```text +${XDG_DATA_HOME:-~/.local/share}/omarchy-subwave/ +``` + +Player sockets, status, PID identity, and logs live in: + +```text +$XDG_RUNTIME_DIR/omarchy-subwave/ +``` + +## Remove + +Stop the dedicated player before removing the plugin: + +```bash +~/.config/omarchy/plugins/getsubwave.radio/subwave-player stop +omarchy plugin remove getsubwave.radio +``` + +The last station, volume, and directory cache remain under +`~/.local/share/omarchy-subwave/`. Remove that directory manually only if you +also want to delete those preferences. + +## Troubleshooting + +Check the helper contracts directly: + +```bash +~/.config/omarchy/plugins/getsubwave.radio/subwave-fetch catalog | jq 'length' +~/.config/omarchy/plugins/getsubwave.radio/subwave-fetch now-playing https://radio.example.com | jq . +~/.config/omarchy/plugins/getsubwave.radio/subwave-player status | jq . +``` + +MPV diagnostics are written to +`$XDG_RUNTIME_DIR/omarchy-subwave/mpv.log`. If the bar does not pick up a saved +plugin change, run: + +```bash +omarchy-shell shell rescanPlugins +``` + +Malformed or oversized saved data is not overwritten automatically. Back up +the affected file under `~/.local/share/omarchy-subwave/` before repairing or +removing it. + +## Development + +```bash +./tests/run +omarchy plugin validate . +qmllint -I /usr/share/omarchy/shell BarWidget.qml StationPicker.qml +``` + +The test suite uses temporary XDG directories and fake network/player +boundaries; it does not tune a real station. The final QML checks require an +Omarchy installation because they import the installed shell components. + +## License + +MIT diff --git a/StationModel.js b/StationModel.js new file mode 100644 index 0000000..0fb93b6 --- /dev/null +++ b/StationModel.js @@ -0,0 +1,97 @@ +var MAX_STATIONS = 200 +var MAX_FIELD = 512 + +function singleLine(value, limit) { + var cap = Math.max(0, Math.min(MAX_FIELD, Number(limit) || MAX_FIELD)) + return String(value || "").replace(/[\r\n\t\u0000-\u001f\u007f]+/g, " ").trim().slice(0, cap) +} + +function normalizeOrigin(value) { + var raw = String(value || "").trim() + if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) return "" + var matched = raw.match(/^(https?):\/\/([^\/@?#\s]+)(?:\/[^?#]*)?\/?$/i) + if (!matched) return "" + return matched[1].toLowerCase() + "://" + matched[2] +} + +function fallbackSlug(name) { + return singleLine(name, 80).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 49) +} + +function normalizeStation(value) { + if (!value || typeof value !== "object") return null + var name = singleLine(value.name, 160) + var url = normalizeOrigin(value.url) + if (!name || !url) return null + return { + slug: singleLine(value.slug, 49) || fallbackSlug(name), + name: name, + url: url, + location: singleLine(value.location, 160), + country: singleLine(value.country, 100), + operator: singleLine(value.operator, 100), + genre: singleLine(value.genre, 160), + description: singleLine(value.description, MAX_FIELD), + featured: value.featured === true, + submitted: singleLine(value.submitted, 32), + isConfigured: value.isConfigured === true + } +} + +function compareStations(a, b) { + if (a.featured !== b.featured) return a.featured ? -1 : 1 + var left = a.name.toLowerCase() + var right = b.name.toLowerCase() + return left < right ? -1 : (left > right ? 1 : 0) +} + +function normalizeCatalog(value) { + var source = Array.isArray(value) ? value : [] + var seen = ({}) + var rows = [] + for (var i = 0; i < source.length; i++) { + var station = normalizeStation(source[i]) + if (!station || seen[station.url]) continue + seen[station.url] = true + rows.push(station) + } + rows.sort(compareStations) + return rows.slice(0, MAX_STATIONS) +} + +function mergeConfigured(stations, stationUrl) { + var rows = normalizeCatalog(stations) + var origin = normalizeOrigin(stationUrl) + if (!origin) return rows + for (var i = 0; i < rows.length; i++) { + if (rows[i].url !== origin) continue + var matched = Object.assign({}, rows[i], { isConfigured: true }) + rows.splice(i, 1) + rows.unshift(matched) + return rows + } + rows.unshift({ + slug: "__configured", + name: "My station", + url: origin, + location: "", + country: "", + operator: "", + genre: "", + description: "Your configured SUB/WAVE station", + featured: false, + submitted: "", + isConfigured: true + }) + return rows.slice(0, MAX_STATIONS) +} + +function searchStations(stations, query) { + var rows = Array.isArray(stations) ? stations : [] + var needle = singleLine(query, 160).toLowerCase() + if (!needle) return rows.slice() + return rows.filter(function(station) { + return [station.name, station.genre, station.location, station.country, + station.operator, station.description].join(" ").toLowerCase().indexOf(needle) !== -1 + }) +} diff --git a/StationPicker.qml b/StationPicker.qml new file mode 100644 index 0000000..de30990 --- /dev/null +++ b/StationPicker.qml @@ -0,0 +1,387 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons +import qs.Ui +import "StationModel.js" as StationModel + +Item { + id: root + + property string omarchyPath: Quickshell.env("OMARCHY_PATH") + property var shell: null + property var manifest: null + property bool opened: false + property string configuredUrl: "" + property var allStations: [] + property string filterText: "" + property int selectedIndex: 0 + property var liveByUrl: ({}) + property int liveRevision: 0 + property var probeQueue: [] + property string probingUrl: "" + property string errorText: "" + property string playingUrl: "" + readonly property string fetchPath: Qt.resolvedUrl("subwave-fetch").toString().replace(/^file:\/\//, "") + readonly property string playerPath: Qt.resolvedUrl("subwave-player").toString().replace(/^file:\/\//, "") + readonly property color background: Color.menu.background + readonly property color foreground: Color.menu.text + readonly property color border: Color.menu.border + readonly property color scrim: Color.menu.scrim + readonly property color accent: Color.accent + readonly property int cardWidth: Math.min(Style.space(680), panel.width - Style.gapsOut * 2) + readonly property int cardHeight: Math.min(Style.space(650), panel.height - Style.gapsOut * 2) + + function open(payloadJson) { + var payload = ({}) + try { payload = JSON.parse(payloadJson || "{}") } catch (error) {} + configuredUrl = StationModel.normalizeOrigin(payload.stationUrl || "") + opened = true + errorText = "" + filterText = "" + selectedIndex = 0 + loadCache() + Qt.callLater(function() { searchField.forceActiveFocus() }) + } + + function close() { + opened = false + probeTimer.stop() + probeQueue = [] + } + + function dismiss() { + close() + if (shell && typeof shell.hide === "function") shell.hide((manifest && manifest.id) || "getsubwave.radio") + } + + function loadCache() { + if (cacheProcess.running) return + cacheProcess.command = [fetchPath, "cache"] + cacheProcess.running = true + } + + function refreshCatalog() { + if (catalogProcess.running) return + catalogProcess.command = [fetchPath, "catalog"] + catalogProcess.running = true + } + + function applyCatalog(raw) { + try { + if (typeof raw !== "string" || raw.length > 1048576) throw new Error("Directory response is too large") + var parsed = JSON.parse(raw || "[]") + allStations = StationModel.mergeConfigured(parsed, configuredUrl) + rebuildVisible() + queueVisibleProbes() + } catch (error) { + errorText = "Station directory unavailable" + } + } + + function rebuildVisible() { + var rows = StationModel.searchStations(allStations, filterText) + visibleStations.clear() + for (var i = 0; i < rows.length; i++) visibleStations.append(rows[i]) + selectedIndex = Math.max(0, Math.min(selectedIndex, visibleStations.count - 1)) + stationList.currentIndex = selectedIndex + } + + function setFilter(value) { + filterText = StationModel.singleLine(value, 160) + rebuildVisible() + queueVisibleProbes() + } + + function moveSelection(delta) { + if (!visibleStations.count) return + selectedIndex = (selectedIndex + delta + visibleStations.count) % visibleStations.count + stationList.currentIndex = selectedIndex + stationList.positionViewAtIndex(selectedIndex, ListView.Contain) + } + + function playSelected() { + if (!visibleStations.count || playProcess.running) return + var station = visibleStations.get(selectedIndex) + if (playingUrl === station.url) { + dismiss() + return + } + errorText = "" + playProcess.stationUrl = station.url + playProcess.command = [playerPath, "play", station.url, station.name] + playProcess.running = true + } + + function queueVisibleProbes() { + var next = [] + var limit = Math.min(24, visibleStations.count) + for (var i = 0; i < limit; i++) { + var url = visibleStations.get(i).url + if (url !== probingUrl) next.push(url) + } + probeQueue = next + startNextProbe() + } + + function startNextProbe() { + if (probeProcess.running || !opened || probeQueue.length === 0) return + var next = probeQueue.slice() + probingUrl = next.shift() + probeQueue = next + probeProcess.command = [fetchPath, "now-playing", probingUrl] + probeProcess.running = true + } + + function applyProbe(url, raw) { + var state = ({ online: false, error: "Station unavailable" }) + try { + if (typeof raw === "string" && raw.length <= 65536) state = JSON.parse(raw || "{}") + } catch (error) {} + var copy = ({}) + for (var key in liveByUrl) copy[key] = liveByUrl[key] + copy[url] = state + liveByUrl = copy + liveRevision++ + } + + function liveFor(url) { + var revision = liveRevision + return liveByUrl[url] || null + } + + ListModel { id: visibleStations } + + Process { + id: cacheProcess + command: [] + stdout: StdioCollector { id: cacheOutput; waitForEnd: true } + onExited: { + root.applyCatalog(cacheOutput.text) + root.refreshCatalog() + } + } + + Process { + id: catalogProcess + command: [] + stdout: StdioCollector { id: catalogOutput; waitForEnd: true } + onExited: function(exitCode) { + if (exitCode === 0) root.applyCatalog(catalogOutput.text) + else if (root.allStations.length === 0) root.errorText = "Could not refresh the station directory" + } + } + + Process { + id: probeProcess + command: [] + stdout: StdioCollector { id: probeOutput; waitForEnd: true } + onExited: { + root.applyProbe(root.probingUrl, probeOutput.text) + root.probingUrl = "" + root.startNextProbe() + } + } + + Process { + id: playProcess + property string stationUrl: "" + command: [] + stdout: StdioCollector { id: playOutput; waitForEnd: true } + stderr: StdioCollector { id: playError; waitForEnd: true } + onExited: function(exitCode) { + if (exitCode === 0) { + root.playingUrl = stationUrl + root.queueVisibleProbes() + } else root.errorText = StationModel.singleLine(playError.text, 200) || "Could not start this station" + } + } + + Timer { + id: probeTimer + interval: 30000 + repeat: true + running: root.opened + onTriggered: root.queueVisibleProbes() + } + + PanelWindow { + id: panel + visible: root.opened + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + WlrLayershell.namespace: "omarchy-subwave" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: root.opened ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + + Rectangle { anchors.fill: parent; color: root.scrim } + MouseArea { anchors.fill: parent; onClicked: root.dismiss() } + + BorderSurface { + id: card + width: root.cardWidth + height: root.cardHeight + anchors.centerIn: parent + radius: Style.cornerRadius + color: root.background + borderSpec: Border.surfaceSpec("menu", "border", root.border, Math.max(1, Style.space(2))) + + MouseArea { anchors.fill: parent; onClicked: {} } + + Column { + anchors.fill: parent + anchors.margins: Style.spacing.panelPadding + spacing: Style.spacing.md + + Row { + width: parent.width + spacing: Style.spacing.md + + Text { + text: "SUB/WAVE" + color: root.foreground + font.family: Style.font.menuFamily + font.pixelSize: Style.font.title + font.bold: true + } + + Text { + width: parent.width - x + text: visibleStations.count + " stations" + color: root.foreground + opacity: 0.58 + font.family: Style.font.menuFamily + font.pixelSize: Style.font.body + horizontalAlignment: Text.AlignRight + } + } + + TextField { + id: searchField + width: parent.width + placeholderText: "Search stations, places, genres…" + foreground: root.foreground + accent: root.accent + font.family: Style.font.menuFamily + font.pixelSize: Style.font.body + onTextChanged: root.setFilter(text) + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Down) { root.moveSelection(1); event.accepted = true } + else if (event.key === Qt.Key_Up) { root.moveSelection(-1); event.accepted = true } + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.playSelected(); event.accepted = true } + else if (event.key === Qt.Key_Escape) { + if (text) text = "" + else root.dismiss() + event.accepted = true + } + } + } + + Text { + visible: root.errorText !== "" + width: parent.width + text: root.errorText + color: Color.urgent + font.family: Style.font.menuFamily + font.pixelSize: Style.font.caption + wrapMode: Text.Wrap + } + + ListView { + id: stationList + width: parent.width + height: parent.height - y + clip: true + spacing: Style.spacing.xs + model: visibleStations + currentIndex: root.selectedIndex + + delegate: BorderSurface { + required property int index + required property string name + required property string url + required property string location + required property string country + required property string genre + required property bool featured + required property bool isConfigured + readonly property var live: root.liveFor(url) + width: ListView.view.width + height: Style.space(68) + radius: Style.spacing.labelGap + color: index === root.selectedIndex + ? Color.menu.selectedBackground + : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.025) + borderSpec: index === root.selectedIndex + ? Border.surfaceSpec("menu", "selected-border", Color.menu.selectedBorder, 1) + : Border.flat(Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08), 1) + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.selectedIndex = index + onClicked: { root.selectedIndex = index; root.playSelected() } + } + + Column { + anchors.left: parent.left + anchors.right: status.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(14) + anchors.rightMargin: Style.space(12) + spacing: Style.space(4) + + Text { + width: parent.width + text: name + (isConfigured ? " · MY STATION" : (featured ? " · FEATURED" : "")) + color: root.foreground + font.family: Style.font.menuFamily + font.pixelSize: Style.font.body + font.bold: true + elide: Text.ElideRight + } + + Text { + width: parent.width + text: live && live.title + ? live.title + (live.artist ? " · " + live.artist : "") + : [location || country, genre].filter(function(value) { return value }).join(" · ") + color: root.foreground + opacity: 0.62 + font.family: Style.font.menuFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + + Text { + id: status + anchors.right: parent.right + anchors.rightMargin: Style.space(14) + anchors.verticalCenter: parent.verticalCenter + text: root.playingUrl === url ? "PLAYING" : (!live ? "CHECKING" : (live.online ? "ON AIR" : "OFFLINE")) + color: live && live.online ? root.accent : root.foreground + opacity: live && live.online ? 1 : 0.5 + font.family: Style.font.menuFamily + font.pixelSize: Style.font.caption + font.bold: true + } + } + + Text { + anchors.centerIn: parent + visible: visibleStations.count === 0 + text: root.filterText ? "No matching stations" : "No stations available" + color: root.foreground + opacity: 0.58 + font.family: Style.font.menuFamily + font.pixelSize: Style.font.body + } + } + } + } + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b4eb562 --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "getsubwave.radio", + "name": "SUB/WAVE Radio", + "version": "0.1.0", + "author": "SUB/WAVE", + "license": "MIT", + "description": "Listen to your SUB/WAVE station and discover community stations from the Omarchy bar.", + "homepage": "https://github.com/getsubwave/omarchy-subwave", + "repository": "https://github.com/getsubwave/omarchy-subwave", + "keywords": ["radio", "subwave", "music", "mpris"], + "kinds": ["overlay", "bar-widget"], + "keepLoaded": true, + "entryPoints": { + "overlay": "StationPicker.qml", + "barWidget": "BarWidget.qml" + }, + "barWidget": { + "displayName": "SUB/WAVE Radio", + "description": "SUB/WAVE community radio player", + "category": "Media", + "allowMultiple": false, + "defaultSection": "left" + } +} diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..76aecd1 Binary files /dev/null and b/preview.png differ diff --git a/subwave-fetch b/subwave-fetch new file mode 100755 index 0000000..3d561fb --- /dev/null +++ b/subwave-fetch @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +data_root=${XDG_DATA_HOME:-$HOME/.local/share} +data_dir="$data_root/omarchy-subwave" +cache_file="$data_dir/catalog.json" +catalog_url=${SUBWAVE_CATALOG_URL:-https://www.getsubwave.com/stations.json} +max_catalog_bytes=1048576 +max_now_bytes=262144 +install -d -m 700 "$data_dir" +version=$(jq -r '.version // "0.0.0"' "$script_dir/manifest.json" 2>/dev/null || printf '0.0.0') +user_agent="Omarchy SUB/WAVE/$version" + +normalize_origin() { + local raw=${1:-} + [[ -n $raw && ! $raw =~ [[:cntrl:]] ]] || return 1 + if [[ $raw =~ ^(https?)://([^/@?#[:space:]]+)(/[^?#]*)?/?$ ]]; then + printf '%s://%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + return 1 +} + +valid_cache() { + [[ -s $cache_file ]] \ + && (( $(stat -c '%s' -- "$cache_file") <= max_catalog_bytes )) \ + && jq -e 'type == "array" and length <= 200' "$cache_file" >/dev/null 2>&1 +} + +emit_cache() { + if valid_cache; then cat "$cache_file"; else printf '[]\n'; fi +} + +normalize_catalog() { + jq -c ' + def line($n): tostring | gsub("[\u0000-\u001f\u007f]"; " ") | gsub("[ \t]+"; " ") | ltrimstr(" ") | rtrimstr(" ") | .[0:$n]; + def origin: + select(type == "string") + | try capture("^(?https?)://(?[^/@?#[:space:]]+)(?:/[^?#]*)?/?$") catch null + | select(. != null) + | "\(.scheme)://\(.host)"; + if type != "array" then error("catalog is not an array") else . end + | map( + . as $row + | (($row.name // "") | line(160)) as $name + | (($row.url // "") | origin) as $url + | select($name != "" and $url != "") + | { + slug: (($row.slug // ($name | ascii_downcase | gsub("[^a-z0-9]+"; "-") | ltrimstr("-") | rtrimstr("-"))) | line(49)), + name: $name, + url: $url, + location: (($row.location // "") | line(160)), + country: (($row.country // "") | line(100)), + operator: (($row.operator // "") | line(100)), + genre: (($row.genre // "") | line(160)), + description: (($row.description // "") | line(512)), + featured: ($row.featured == true), + submitted: (($row.submitted // "") | line(32)), + isConfigured: false + } + ) + | sort_by([if .featured then 0 else 1 end, (.name | ascii_downcase)]) + | reduce .[] as $row ([]; if any(.[]; .url == $row.url) then . else . + [$row] end) + | .[:200] + ' +} + +write_cache() { + local source=$1 temporary + temporary=$(mktemp "$data_dir/.catalog.XXXXXX") + install -m 600 "$source" "$temporary" + mv -f "$temporary" "$cache_file" +} + +fetch_catalog() { + local download normalized + download=$(mktemp "$data_dir/.download.XXXXXX") + normalized=$(mktemp "$data_dir/.normalized.XXXXXX") + trap 'rm -f "$download" "$normalized"' RETURN + if ! curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 4 --max-time 10 --max-filesize "$max_catalog_bytes" \ + --user-agent "$user_agent" --output "$download" "$catalog_url"; then + emit_cache + valid_cache + return + fi + if (( $(stat -c '%s' -- "$download") > max_catalog_bytes )) \ + || ! normalize_catalog <"$download" >"$normalized"; then + emit_cache + valid_cache + return + fi + write_cache "$normalized" + cat "$cache_file" +} + +offline_json() { + jq -cn '{online:false,station:"",dj:"",show:"",title:"",artist:"",album:"",coverUrl:"",listeners:null,error:"Station unavailable"}' +} + +fetch_now_playing() { + local origin scheme response + if ! origin=$(normalize_origin "${1:-}"); then + offline_json + return 2 + fi + scheme=${origin%%:*} + response=$(mktemp "$data_dir/.now.XXXXXX") + trap 'rm -f "$response"' RETURN + if ! curl --fail --silent --show-error --location \ + --proto "=$scheme" --proto-redir "=$scheme" \ + --connect-timeout 3 --max-time 6 --max-filesize "$max_now_bytes" \ + --user-agent "$user_agent" --output "$response" "$origin/api/now-playing" \ + || (( $(stat -c '%s' -- "$response") > max_now_bytes )); then + offline_json + return 3 + fi + if ! jq -ce --arg origin "$origin" ' + def line($n): tostring | gsub("[\u0000-\u001f\u007f]"; " ") | gsub("[ \t]+"; " ") | ltrimstr(" ") | rtrimstr(" ") | .[0:$n]; + if type != "object" then error("invalid now-playing") else . end + | (.nowPlaying // {}) as $np + | ($np.subsonic_id // "") as $id + | { + online: (.streamOnline != false), + station: ((.dj.station // "") | line(160)), + dj: ((.dj.name // "") | line(100)), + show: ((.activeShow.name // "") | line(160)), + title: (($np.title // "") | line(512)), + artist: (($np.artist // "") | line(512)), + album: (($np.album // "") | line(512)), + coverUrl: (if ($id | type == "string" and test("^[A-Za-z0-9_-]{1,64}$")) then ($origin + "/api/cover/" + $id) else "" end), + listeners: ((if (.listeners | type) == "number" then .listeners elif (.listeners.current | type) == "number" then .listeners.current else null end) as $n | if $n != null and $n >= 0 then ($n | floor) else null end), + error: "" + } + ' "$response"; then + offline_json + return 4 + fi +} + +case ${1:-} in + cache) emit_cache ;; + catalog) fetch_catalog ;; + now-playing) fetch_now_playing "${2:-}" ;; + *) echo "Usage: subwave-fetch cache|catalog|now-playing " >&2; exit 2 ;; +esac diff --git a/subwave-player b/subwave-player new file mode 100755 index 0000000..eb63ecb --- /dev/null +++ b/subwave-player @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +: "${XDG_RUNTIME_DIR:?SUB/WAVE requires XDG_RUNTIME_DIR}" +runtime_dir="$XDG_RUNTIME_DIR/omarchy-subwave" +data_root=${XDG_DATA_HOME:-$HOME/.local/share} +data_dir="$data_root/omarchy-subwave" +state_file="$data_dir/state.json" +socket="$runtime_dir/mpv.sock" +pid_file="$runtime_dir/player.pid" +station_file="$runtime_dir/station.json" +status_file="$runtime_dir/status.json" +log_file="$runtime_dir/mpv.log" +install -d -m 700 "$runtime_dir" "$data_dir" +exec 8>"$runtime_dir/player.lock" + +normalize_origin() { + local raw=${1:-} + [[ -n $raw && ! $raw =~ [[:cntrl:]] ]] || return 1 + if [[ $raw =~ ^(https?)://([^/@?#[:space:]]+)(/[^?#]*)?/?$ ]]; then + printf '%s://%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + return 1 +} + +process_start_time() { + local pid=$1 + [[ -r /proc/$pid/stat ]] || return 1 + awk '$3 != "Z" { print $22 }' "/proc/$pid/stat" 2>/dev/null +} + +player_alive() { + local pid expected actual + [[ -s $pid_file ]] || return 1 + read -r pid expected <"$pid_file" || return 1 + [[ $pid =~ ^[0-9]+$ && $expected =~ ^[0-9]+$ ]] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + actual=$(process_start_time "$pid") || return 1 + [[ $actual == "$expected" ]] +} + +valid_state() { + [[ -s $state_file ]] && (( $(stat -c '%s' -- "$state_file") <= 65536 )) \ + && jq -e 'type == "object" and ((.volume // 70) | type == "number")' "$state_file" >/dev/null 2>&1 +} + +read_state() { + if valid_state; then + jq -c '{volume: ((.volume // 70) | floor | if . < 0 then 0 elif . > 100 then 100 else . end), lastStation: (.lastStation // {url:"",name:""})}' "$state_file" + else + jq -cn '{volume:70,lastStation:{url:"",name:""}}' + fi +} + +write_state() { + local json=$1 temporary + temporary=$(mktemp "$data_dir/.state.XXXXXX") + printf '%s\n' "$json" >"$temporary" + chmod 600 "$temporary" + mv -f "$temporary" "$state_file" +} + +configured_volume() { + read_state | jq -r '.volume' +} + +current_station() { + if [[ -s $station_file ]] && (( $(stat -c '%s' -- "$station_file") <= 65536 )) \ + && jq -e 'type == "object" and (.url | type == "string")' "$station_file" >/dev/null 2>&1; then + cat "$station_file" + else + jq -cn '{name:"",url:""}' + fi +} + +write_station() { + local origin=$1 name=$2 temporary state + temporary=$(mktemp "$runtime_dir/.station.XXXXXX") + jq -cn --arg url "$origin" --arg name "$name" '{name:($name | gsub("[\\r\\n\\t]";" ") | .[:160]),url:$url}' >"$temporary" + mv -f "$temporary" "$station_file" + state=$(read_state | jq -c --argjson station "$(cat "$station_file")" '.lastStation = $station') + write_state "$state" +} + +cleanup_stale() { + player_alive && return 0 + rm -f "$pid_file" "$socket" "$station_file" + return 1 +} + +send_command() { + local payload=$1 response request_id=7101 + [[ -S $socket ]] || return 1 + payload=$(jq -c --argjson id "$request_id" '. + {request_id:$id}' <<<"$payload") + response=$(printf '%s\n' "$payload" | socat -T 1 - "UNIX-CONNECT:$socket" 2>/dev/null | head -c 65537) || return 1 + (( ${#response} <= 65536 )) || return 1 + jq -e --argjson id "$request_id" 'select(.request_id == $id and .error == "success")' <<<"$response" >/dev/null || return 1 + printf '%s\n' "$response" +} + +property() { + local name=$1 payload + payload=$(jq -cn --arg name "$name" '{command:["get_property",$name]}') + send_command "$payload" | jq -c '.data' +} + +write_status() { + local json=$1 temporary + temporary=$(mktemp "$runtime_dir/.status.XXXXXX") + printf '%s\n' "$json" >"$temporary" + mv -f "$temporary" "$status_file" + printf '%s\n' "$json" +} + +status() { + local volume station paused=false + volume=$(configured_volume) + if ! player_alive || [[ ! -S $socket ]]; then + cleanup_stale || true + write_status "$(jq -cn --argjson volume "$volume" '{running:false,paused:false,volume:$volume,station:{name:"",url:""},error:""}')" + return + fi + paused=$(property pause 2>/dev/null || printf false) + volume=$(property volume 2>/dev/null || printf '%s' "$volume") + station=$(current_station) + write_status "$(jq -cn --argjson paused "$paused" --argjson volume "$volume" --argjson station "$station" '{running:true,paused:($paused == true),volume:($volume|floor),station:$station,error:""}')" +} + +start_player() { + local origin=$1 name=$2 volume pid start + volume=$(configured_volume) + rm -f "$socket" + setsid mpv \ + --no-video \ + --force-window=no \ + --audio-display=no \ + --idle=yes \ + --cache-secs=20 \ + --demuxer-max-bytes=8MiB \ + --demuxer-max-back-bytes=2MiB \ + --network-timeout=30 \ + --volume="$volume" \ + --volume-max=100 \ + --input-ipc-server="$socket" \ + --no-terminal \ + --really-quiet \ + "$origin/stream.mp3" >"$log_file" 2>&1 8>&- & + pid=$! + start=$(process_start_time "$pid") || { kill "$pid" 2>/dev/null || true; return 1; } + printf '%s %s\n' "$pid" "$start" >"$pid_file" + for _ in $(seq 1 150); do + [[ -S $socket ]] && break + sleep 0.02 + done + if [[ ! -S $socket ]]; then + kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + rm -f "$pid_file" "$socket" + return 1 + fi + write_station "$origin" "$name" +} + +play() { + local origin name payload + origin=$(normalize_origin "${1:-}") || { echo "Station URL must be an HTTP(S) origin" >&2; exit 2; } + name=${2:-SUB/WAVE} + if player_alive && [[ -S $socket ]]; then + payload=$(jq -cn --arg url "$origin/stream.mp3" '{command:["loadfile",$url,"replace"]}') + send_command "$payload" >/dev/null || { echo "Player could not load station" >&2; exit 4; } + write_station "$origin" "$name" + else + cleanup_stale || true + start_player "$origin" "$name" || { echo "Player could not start" >&2; exit 4; } + fi + status +} + +set_volume() { + local volume=${1:-} payload state + [[ $volume =~ ^[0-9]+$ ]] && (( volume >= 0 && volume <= 100 )) \ + || { echo "Volume must be an integer from 0 to 100" >&2; exit 2; } + if player_alive && [[ -S $socket ]]; then + payload=$(jq -cn --argjson volume "$volume" '{command:["set_property","volume",$volume]}') + send_command "$payload" >/dev/null || { echo "Player volume could not be changed" >&2; exit 4; } + fi + state=$(read_state | jq -c --argjson volume "$volume" '.volume = $volume') + write_state "$state" + status +} + +transport() { + local payload=$1 + player_alive && [[ -S $socket ]] || { echo "Player is unavailable" >&2; exit 4; } + send_command "$payload" >/dev/null || { echo "Player command failed" >&2; exit 4; } + status +} + +stop_player() { + local pid + if ! player_alive; then + cleanup_stale || true + status + return + fi + read -r pid _ <"$pid_file" + send_command '{"command":["quit"]}' >/dev/null 2>&1 || true + for _ in $(seq 1 100); do + player_alive || break + sleep 0.02 + done + if player_alive; then + kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + fi + for _ in $(seq 1 50); do + player_alive || break + sleep 0.02 + done + if player_alive; then + kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true + fi + rm -f "$pid_file" "$socket" "$station_file" + status +} + +flock -x 8 +case ${1:-status} in + play) play "${2:-}" "${3:-SUB/WAVE}" ;; + toggle) transport '{"command":["cycle","pause"]}' ;; + pause) transport '{"command":["set_property","pause",true]}' ;; + stop) stop_player ;; + volume) set_volume "${2:-}" ;; + status) status ;; + *) echo "Usage: subwave-player play|toggle|pause|stop|volume|status" >&2; exit 2 ;; +esac diff --git a/tests/fetch.test.sh b/tests/fetch.test.sh new file mode 100755 index 0000000..0dabaa0 --- /dev/null +++ b/tests/fetch.test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/data" "$tmp/runtime" +export XDG_DATA_HOME="$tmp/data" +export XDG_RUNTIME_DIR="$tmp/runtime" +export PATH="$tmp/bin:$PATH" + +cat >"$tmp/bin/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +output= +printf '%s\n' "$@" >"${FAKE_CURL_ARGS:?}" +while (($#)); do + if [[ $1 == --output ]]; then output=$2; shift 2; else shift; fi +done +if [[ ${FAKE_CURL_EXIT:-0} != 0 ]]; then exit "$FAKE_CURL_EXIT"; fi +cp "${FAKE_CURL_BODY:?}" "$output" +SH +chmod +x "$tmp/bin/curl" +export FAKE_CURL_ARGS="$tmp/curl.args" + +cat >"$tmp/catalog.json" <<'JSON' +[ + {"slug":"plain","name":"Plain","url":"https://radio.example/path","genre":"Jazz"}, + {"slug":"featured","name":"Featured","url":"https://featured.example","featured":true}, + {"slug":"bad","name":"","url":"https://bad.example"} +] +JSON +FAKE_CURL_BODY="$tmp/catalog.json" "$root/subwave-fetch" catalog >"$tmp/out.json" +jq -e 'length == 2 and .[0].slug == "featured" and .[1].url == "https://radio.example"' "$tmp/out.json" >/dev/null +test "$(stat -c %a "$XDG_DATA_HOME/omarchy-subwave/catalog.json")" = 600 +grep -Fx 'Omarchy SUB/WAVE/0.1.0' "$tmp/curl.args" >/dev/null + +FAKE_CURL_EXIT=22 "$root/subwave-fetch" catalog >"$tmp/cached.json" +cmp "$tmp/out.json" "$tmp/cached.json" +"$root/subwave-fetch" cache >"$tmp/cache-command.json" +cmp "$tmp/out.json" "$tmp/cache-command.json" + +cat >"$tmp/now.json" <<'JSON' +{"nowPlaying":{"title":"Track","artist":"Artist","album":"Album","subsonic_id":"abc"},"dj":{"station":"Example","name":"Frequency"},"activeShow":{"name":"Night Drive"},"listeners":{"current":4},"streamOnline":true} +JSON +FAKE_CURL_BODY="$tmp/now.json" "$root/subwave-fetch" now-playing https://radio.example >"$tmp/now-out.json" +jq -e '.online == true and .title == "Track" and .artist == "Artist" and .listeners == 4 and .coverUrl == "https://radio.example/api/cover/abc"' "$tmp/now-out.json" >/dev/null + +if "$root/subwave-fetch" now-playing 'https://u:p@radio.example' >/dev/null 2>&1; then + echo "credentialed URL was accepted" >&2 + exit 1 +fi + +dd if=/dev/zero of="$tmp/large.json" bs=1048577 count=1 status=none +before=$(sha256sum "$XDG_DATA_HOME/omarchy-subwave/catalog.json") +FAKE_CURL_BODY="$tmp/large.json" "$root/subwave-fetch" catalog >"$tmp/large-out.json" +after=$(sha256sum "$XDG_DATA_HOME/omarchy-subwave/catalog.json") +test "$before" = "$after" +cmp "$tmp/out.json" "$tmp/large-out.json" + +echo "subwave-fetch tests passed" diff --git a/tests/model.test.mjs b/tests/model.test.mjs new file mode 100644 index 0000000..157ed79 --- /dev/null +++ b/tests/model.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import vm from "node:vm" +import { fileURLToPath } from "node:url" + +const testDir = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(testDir, "..", "StationModel.js"), "utf8") +// QML's JavaScript engine does not provide the browser/Node URL constructor. +const model = { Array, Boolean, JSON, Math, Number, Object, RegExp, String } +vm.createContext(model) +vm.runInContext(source, model) + +assert.equal(model.normalizeOrigin(" https://radio.example.com/path/ "), "https://radio.example.com") +assert.equal(model.normalizeOrigin("http://radio.example.com:7700"), "http://radio.example.com:7700") +assert.equal(model.normalizeOrigin("https://user:pass@radio.example.com"), "") +assert.equal(model.normalizeOrigin("file:///tmp/stream"), "") +assert.equal(model.normalizeOrigin("https://radio.example.com/#secret"), "") +assert.equal(model.normalizeOrigin("https://radio.example.com/\nnext"), "") + +const rows = model.normalizeCatalog([ + { slug: "zeta", name: "Zeta", url: "https://zeta.example", genre: "Jazz" }, + { slug: "featured", name: "Featured", url: "https://featured.example", featured: true }, + { slug: "bad", name: "", url: "https://bad.example" }, + { slug: "creds", name: "Creds", url: "https://u:p@bad.example" } +]) +assert.deepEqual(Array.from(rows, row => row.slug), ["featured", "zeta"]) + +const merged = model.mergeConfigured(rows, "https://zeta.example/listen") +assert.equal(merged.length, 2) +assert.equal(merged[0].slug, "zeta") +assert.equal(merged[0].isConfigured, true) + +const synthetic = model.mergeConfigured(rows, "https://mine.example") +assert.equal(synthetic[0].slug, "__configured") +assert.equal(synthetic[0].name, "My station") +assert.equal(synthetic[0].url, "https://mine.example") + +assert.deepEqual( + Array.from(model.searchStations(synthetic, "jazz"), row => row.slug), + ["zeta"] +) +assert.equal(model.searchStations(synthetic, "").length, 3) +assert.equal(model.normalizeCatalog(Array.from({ length: 250 }, (_, i) => ({ + slug: `s-${i}`, name: `Station ${i}`, url: `https://s-${i}.example` +}))).length, 200) + +assert.equal(model.singleLine(" a\n\tb ", 20), "a b") +assert.equal(model.singleLine("abcdef", 3), "abc") + +console.log("StationModel tests passed") diff --git a/tests/player.test.sh b/tests/player.test.sh new file mode 100755 index 0000000..9e8b41c --- /dev/null +++ b/tests/player.test.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tmp=$(mktemp -d) +cleanup() { + XDG_RUNTIME_DIR="$tmp/runtime" XDG_DATA_HOME="$tmp/data" "$root/subwave-player" stop >/dev/null 2>&1 || true + rm -rf "$tmp" +} +trap cleanup EXIT +mkdir -p "$tmp/bin" "$tmp/data" "$tmp/runtime" +export XDG_DATA_HOME="$tmp/data" +export XDG_RUNTIME_DIR="$tmp/runtime" +export PATH="$tmp/bin:$PATH" +export FAKE_MPV_ARGS="$tmp/mpv.args" +export FAKE_MPV_VOLUME="$tmp/volume" + +cat >"$tmp/bin/mpv-ipc" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +IFS= read -r request +id=$(jq -r '.request_id' <<<"$request") +action=$(jq -r '.command[0]' <<<"$request") +property=$(jq -r '.command[1] // ""' <<<"$request") +if [[ $action == get_property && $property == pause ]]; then data=false +elif [[ $action == get_property && $property == volume ]]; then data=$(cat "$FAKE_MPV_VOLUME" 2>/dev/null || printf 70) +elif [[ $action == set_property && $property == volume ]]; then + jq -r '.command[2]' <<<"$request" >"$FAKE_MPV_VOLUME" + data=null +else data=null +fi +jq -cn --argjson id "$id" --argjson data "$data" '{request_id:$id,error:"success",data:$data}' +SH +chmod +x "$tmp/bin/mpv-ipc" + +cat >"$tmp/bin/mpv" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >"$FAKE_MPV_ARGS" +socket= +for arg in "$@"; do + [[ $arg == --input-ipc-server=* ]] && socket=${arg#*=} +done +[[ -n $socket ]] +exec socat "UNIX-LISTEN:$socket,fork" "SYSTEM:$PWD/tests/fake-unused" 2>/dev/null +SH +chmod +x "$tmp/bin/mpv" + +# Override socat for the fake MPV listener and for client calls. +real_socat=$(command -v socat) +export REAL_SOCAT="$real_socat" +cat >"$tmp/bin/socat" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ ${1:-} == UNIX-LISTEN:* ]]; then + exec "$REAL_SOCAT" "$1" "SYSTEM:$PATH" >/dev/null +fi +exec "$REAL_SOCAT" "$@" +SH +chmod +x "$tmp/bin/socat" + +# Replace the listener fake with a direct real-socat command after PATH setup. +cat >"$tmp/bin/mpv" <"\$FAKE_MPV_ARGS" +socket= +for arg in "\$@"; do [[ \$arg == --input-ipc-server=* ]] && socket=\${arg#*=}; done +exec "$real_socat" "UNIX-LISTEN:\$socket,fork" "SYSTEM:$tmp/bin/mpv-ipc" +SH +chmod +x "$tmp/bin/mpv" + +"$root/subwave-player" status | jq -e '.running == false and .volume == 70' >/dev/null +if "$root/subwave-player" play 'file:///tmp/audio' 'Bad' >/dev/null 2>&1; then + echo "unsafe player URL was accepted" >&2 + exit 1 +fi +"$root/subwave-player" play 'https://radio.example/path' 'Example Radio' | jq -e '.running == true' >/dev/null +grep -Fx -- '--no-video' "$FAKE_MPV_ARGS" >/dev/null +grep -E '^--input-ipc-server=' "$FAKE_MPV_ARGS" >/dev/null +grep -Fx -- 'https://radio.example/stream.mp3' "$FAKE_MPV_ARGS" >/dev/null +"$root/subwave-player" volume 95 | jq -e '.volume == 95' >/dev/null +if "$root/subwave-player" volume 101 >/dev/null 2>&1; then + echo "out-of-range volume was accepted" >&2 + exit 1 +fi +"$root/subwave-player" toggle | jq -e '.running == true' >/dev/null +"$root/subwave-player" stop | jq -e '.running == false' >/dev/null + +mkdir -p "$XDG_RUNTIME_DIR/omarchy-subwave" +start=$(awk '{print $22}' "/proc/$$/stat") +printf '%s %s\n' "$$" "$((start + 1))" >"$XDG_RUNTIME_DIR/omarchy-subwave/player.pid" +"$root/subwave-player" stop | jq -e '.running == false' >/dev/null +kill -0 "$$" + +echo "subwave-player tests passed" diff --git a/tests/run b/tests/run new file mode 100755 index 0000000..3e6880b --- /dev/null +++ b/tests/run @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +node "$root/tests/model.test.mjs" +bash "$root/tests/fetch.test.sh" +bash "$root/tests/player.test.sh" + +if command -v omarchy >/dev/null 2>&1; then + omarchy plugin validate "$root" +fi +if command -v qmllint >/dev/null 2>&1; then + qmllint -I /usr/share/omarchy/shell "$root/BarWidget.qml" "$root/StationPicker.qml" +fi