Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12cfca8b17 | ||
|
|
0e5b20498a | ||
|
|
e49078ee03 | ||
|
|
f73ea93dde | ||
|
|
d218f4c6f3 | ||
|
|
1f79d7bcfe | ||
|
|
394dc7107c | ||
|
|
f8d10d3a24 | ||
|
|
72485d44ff | ||
|
|
240535506b | ||
|
|
289cb6d4b1 | ||
|
|
9240dc3cb6 |
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Maintains a rolling "latest" release whose AppImage always has the same
|
||||
# download URL, for updaters that poll a static URL (e.g. Shelly) instead of
|
||||
# understanding version tags:
|
||||
#
|
||||
# <server>/<owner>/<repo>/releases/download/latest/WhatsThatMusic-latest-linux-x86_64.AppImage
|
||||
#
|
||||
# Gitea serves ETag and Last-Modified on release assets, and replacing the
|
||||
# asset changes both, which is how those updaters detect a new build.
|
||||
# Gitea has no GitHub-style /releases/latest/download/ redirect, hence this.
|
||||
#
|
||||
# Marked as a pre-release so Gitea's own "latest release" stays the newest vX.Y.Z.
|
||||
#
|
||||
# Required env: TOKEN, API (…/api/v1/repos/<owner>/<repo>), GITHUB_REF_NAME, GITHUB_SHA
|
||||
# Optional env: ARTIFACTS_DIR (default: artifacts)
|
||||
set -euo pipefail
|
||||
|
||||
: "${TOKEN:?TOKEN is required}" "${API:?API is required}"
|
||||
: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" "${GITHUB_SHA:?GITHUB_SHA is required}"
|
||||
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
|
||||
LATEST_TAG="latest"
|
||||
LATEST_NAME="WhatsThatMusic-latest-linux-x86_64.AppImage"
|
||||
|
||||
api() {
|
||||
local method="$1" path="$2"; shift 2
|
||||
local out code body
|
||||
out=$(curl -s -w '\n%{http_code}' -X "$method" -H "Authorization: token $TOKEN" "$API$path" "$@")
|
||||
code=$(printf '%s' "$out" | tail -n1)
|
||||
body=$(printf '%s' "$out" | sed '$d')
|
||||
if [ "$code" -ge 400 ]; then
|
||||
echo "$method $path -> HTTP $code" >&2
|
||||
echo "$body" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body"
|
||||
}
|
||||
|
||||
appimage=$(find "$ARTIFACTS_DIR" -type f -name '*.AppImage' | sort | head -n1)
|
||||
if [ -z "$appimage" ]; then
|
||||
echo "No AppImage under $ARTIFACTS_DIR; cannot update the $LATEST_TAG release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
body_json=$(node -e '
|
||||
console.log(JSON.stringify({
|
||||
body: "Rolling pointer to the newest AppImage, currently " + process.argv[1] + ". " +
|
||||
"The download URL for this asset never changes; versioned downloads are on each vX.Y.Z release.",
|
||||
}));' "$GITHUB_REF_NAME")
|
||||
|
||||
rolling_id=$(api GET "/releases/tags/$LATEST_TAG" 2>/dev/null \
|
||||
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
|
||||
|
||||
if [ -z "${rolling_id:-}" ]; then
|
||||
echo "No '$LATEST_TAG' release yet; creating it."
|
||||
create_json=$(node -e '
|
||||
console.log(JSON.stringify({
|
||||
tag_name: "latest", name: "Latest (rolling)", target_commitish: process.argv[1],
|
||||
prerelease: true, body: JSON.parse(process.argv[2]).body,
|
||||
}));' "$GITHUB_SHA" "$body_json")
|
||||
rolling_id=$(api POST "/releases" -H "Content-Type: application/json" -d "$create_json" \
|
||||
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
|
||||
else
|
||||
api PATCH "/releases/$rolling_id" -H "Content-Type: application/json" -d "$body_json" >/dev/null
|
||||
fi
|
||||
|
||||
# Replace the asset: the new upload gets a new ETag and Last-Modified.
|
||||
old_id=$(api GET "/releases/$rolling_id/assets" \
|
||||
| node -pe "((JSON.parse(require('fs').readFileSync(0,'utf8'))).find(a => a.name === process.argv[1]) || {}).id || ''" "$LATEST_NAME")
|
||||
if [ -n "$old_id" ]; then
|
||||
api DELETE "/releases/$rolling_id/assets/$old_id" >/dev/null
|
||||
fi
|
||||
|
||||
echo "Uploading $LATEST_NAME from $(basename "$appimage") ($(du -h "$appimage" | cut -f1))"
|
||||
api POST "/releases/$rolling_id/assets?name=$LATEST_NAME" -F "attachment=@$appimage" >/dev/null
|
||||
echo "Updated '$LATEST_TAG' release ($rolling_id) to $GITHUB_REF_NAME"
|
||||
+169
-29
@@ -1,8 +1,21 @@
|
||||
name: Build AppImage
|
||||
name: Build release artifacts
|
||||
|
||||
# Builds the Linux AppImage on a version tag and attaches it to a Gitea release.
|
||||
# Tag must match the version in desktop-client/package.json, since electron-builder
|
||||
# names the artifact from package.json (not from the tag).
|
||||
# Builds each platform natively on a v* tag and publishes one Gitea release.
|
||||
#
|
||||
# Runners required. None are registered yet, and a job with no matching runner
|
||||
# queues silently rather than failing, so register all three before tagging:
|
||||
# ubuntu-latest Linux AppImage
|
||||
# windows-latest Windows NSIS installer + portable zip
|
||||
# macos-latest macOS dmg + zip, both architectures (M-series builds both)
|
||||
#
|
||||
# Nothing cross-builds any more: dmg requires macOS, and the Windows exe needs
|
||||
# rcedit on Windows to get its icon and version resources.
|
||||
#
|
||||
# A RELEASE_TOKEN secret is optional; GITEA_TOKEN is used by default.
|
||||
#
|
||||
# All output is unsigned. Once certificates exist: for macOS drop mac.identity
|
||||
# from package.json and remove CSC_IDENTITY_AUTO_DISCOVERY; for Windows add the
|
||||
# cert secrets. Until then users hit Gatekeeper and SmartScreen warnings.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -10,18 +23,19 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
appimage:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop-client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Runs only here rather than in all three jobs: it is a repo-wide check,
|
||||
# and this job uses bash, which is not guaranteed on the Windows runner.
|
||||
- name: Verify tag matches package.json version
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
@@ -29,7 +43,7 @@ jobs:
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
if [ "$pkg_version" != "$tag_version" ]; then
|
||||
echo "Tag $GITHUB_REF_NAME does not match package.json version $pkg_version." >&2
|
||||
echo "Bump package.json before tagging, or the AppImage will be misnamed." >&2
|
||||
echo "Bump package.json before tagging, or artifacts will be misnamed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -42,31 +56,157 @@ jobs:
|
||||
- name: Build AppImage
|
||||
run: npx electron-builder --linux AppImage --publish never
|
||||
|
||||
- name: Attach AppImage to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: linux
|
||||
path: desktop-client/dist/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop-client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build installer and portable zip
|
||||
run: npx electron-builder --win nsis zip --publish never
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: |
|
||||
desktop-client/dist/*.exe
|
||||
desktop-client/dist/*-win-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop-client
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
# No Developer ID yet: stop electron-builder auto-discovering an identity,
|
||||
# which otherwise fails the build on a real Mac rather than building unsigned.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build dmg and zip (both architectures)
|
||||
run: npx electron-builder --mac --x64 --arm64 --publish never
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: macos
|
||||
path: |
|
||||
desktop-client/dist/*.dmg
|
||||
desktop-client/dist/*-mac-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
# Separate job so only one process touches the release: parallel build jobs
|
||||
# would race to create it and one would fail on the duplicate tag.
|
||||
needs: [linux, windows, macos]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Checkout first: it cleans the workspace, which would delete downloaded artifacts.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create release and attach artifacts
|
||||
env:
|
||||
# Gitea injects GITEA_TOKEN automatically, repo-scoped and short-lived.
|
||||
# RELEASE_TOKEN is an optional override if wider rights are ever needed.
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifact=$(ls dist/*.AppImage | head -1)
|
||||
echo "Publishing $artifact for $GITHUB_REF_NAME"
|
||||
|
||||
# Reuse the release if the tag already has one, otherwise create it.
|
||||
release_id=$(curl -sf -H "Authorization: token $TOKEN" \
|
||||
"$API/releases/tags/$GITHUB_REF_NAME" | node -p \
|
||||
"JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
|
||||
|
||||
if [ -z "${release_id:-}" ]; then
|
||||
release_id=$(curl -sf -X POST -H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
"$API/releases" | node -p \
|
||||
"JSON.parse(require('fs').readFileSync(0,'utf8')).id")
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "No token available (GITEA_TOKEN missing and RELEASE_TOKEN unset)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$artifact" \
|
||||
"$API/releases/$release_id/assets?name=$(basename "$artifact")" \
|
||||
-o /dev/null
|
||||
echo "Attached $(basename "$artifact") to release $release_id"
|
||||
# Report the HTTP status and body on failure. A bare `curl -sf | JSON.parse`
|
||||
# dies with "Unexpected end of JSON input" and hides the real cause, which is
|
||||
# how the v1.0.0 run failed on a missing token.
|
||||
api() {
|
||||
local method="$1" path="$2"; shift 2
|
||||
local out code
|
||||
out=$(curl -s -w '\n%{http_code}' -X "$method" \
|
||||
-H "Authorization: token $TOKEN" "$API$path" "$@")
|
||||
code=$(printf '%s' "$out" | tail -n1)
|
||||
body=$(printf '%s' "$out" | sed '$d')
|
||||
if [ "$code" -ge 400 ]; then
|
||||
echo "$method $path -> HTTP $code" >&2
|
||||
echo "$body" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body"
|
||||
}
|
||||
|
||||
# Reuse the release if the tag already has one, otherwise create it.
|
||||
release_id=$(api GET "/releases/tags/$GITHUB_REF_NAME" 2>/dev/null \
|
||||
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
|
||||
|
||||
if [ -z "${release_id:-}" ]; then
|
||||
echo "No release for $GITHUB_REF_NAME yet; creating it."
|
||||
release_id=$(api POST "/releases" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
|
||||
fi
|
||||
|
||||
if [ -z "${release_id:-}" ]; then
|
||||
echo "Could not determine a release id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Publishing to release $release_id"
|
||||
|
||||
# while-read rather than word-splitting a find, so spaces in names are safe.
|
||||
found=0
|
||||
while IFS= read -r f; do
|
||||
name=$(basename "$f")
|
||||
echo "Attaching $name ($(du -h "$f" | cut -f1))"
|
||||
api POST "/releases/$release_id/assets?name=$name" -F "attachment=@$f" >/dev/null
|
||||
found=$((found + 1))
|
||||
done < <(find artifacts -type f \( -name '*.AppImage' -o -name '*.zip' \
|
||||
-o -name '*.dmg' -o -name '*.exe' \) | sort)
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "No artifacts found to attach; build jobs produced nothing." >&2
|
||||
find artifacts -type f | head -20 >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Attached $found artifacts to release $release_id"
|
||||
|
||||
# Constant-URL copy of the AppImage for updaters that poll a static URL
|
||||
# (e.g. Shelly). Runs after the versioned release exists, so a failure here
|
||||
# cannot leave a tag without its assets.
|
||||
- name: Update rolling "latest" AppImage
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: bash .gitea/scripts/update-latest.sh
|
||||
|
||||
@@ -67,8 +67,8 @@ app.whenReady().then(async () => {
|
||||
if (tray) tray.update({ title: next.title, artist: next.artist });
|
||||
});
|
||||
|
||||
await createWindow();
|
||||
|
||||
// Create the tray before the window: the scraper can report a track as soon as
|
||||
// the page loads, and state arriving while `tray` is null would be dropped.
|
||||
tray = createTray({
|
||||
port: serverHandle.port,
|
||||
onShow: () => {
|
||||
@@ -78,6 +78,9 @@ app.whenReady().then(async () => {
|
||||
},
|
||||
onQuit: () => app.quit(),
|
||||
});
|
||||
|
||||
await createWindow();
|
||||
|
||||
}).catch((err) => {
|
||||
dialog.showErrorBox('Failed to start', err && err.message ? err.message : String(err));
|
||||
app.exit(1);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Extracts the current track from a YouTube Music page. `doc` and `nav` are
|
||||
// injected so this stays testable without a browser.
|
||||
|
||||
function largestArtwork(artwork) {
|
||||
if (!artwork || !artwork.length) return null;
|
||||
const area = (a) => {
|
||||
const [w, h] = String(a.sizes || '').split('x').map(Number);
|
||||
return (w || 0) * (h || 0);
|
||||
};
|
||||
return artwork.reduce((best, a) => (area(a) > area(best) ? a : best)).src || null;
|
||||
}
|
||||
|
||||
// Preferred source: YT Music publishes the track via the Media Session API
|
||||
// even when its player-bar markup changes.
|
||||
function fromMediaSession(nav) {
|
||||
const session = nav && nav.mediaSession;
|
||||
const meta = session && session.metadata;
|
||||
if (!meta || !meta.title) return null;
|
||||
return {
|
||||
title: meta.title,
|
||||
artist: meta.artist || null,
|
||||
albumArt: largestArtwork(meta.artwork),
|
||||
isPlaying: session.playbackState === 'playing',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for older YT Music layouts that still render ytmusic-player-bar.
|
||||
function fromPlayerBar(doc) {
|
||||
const titleEl =
|
||||
doc.querySelector('ytmusic-player-bar .title') ||
|
||||
doc.querySelector('.ytmusic-player-bar .title');
|
||||
const title = titleEl ? titleEl.textContent.trim() : null;
|
||||
if (!title) return null;
|
||||
|
||||
const bylineEl =
|
||||
doc.querySelector('ytmusic-player-bar .byline') ||
|
||||
doc.querySelector('.ytmusic-player-bar .byline');
|
||||
const artImg =
|
||||
doc.querySelector('ytmusic-player-bar .middle-controls .thumbnail-image-wrapper img') ||
|
||||
doc.querySelector('ytmusic-player-bar .image img') ||
|
||||
doc.querySelector('ytmusic-player-bar img.image');
|
||||
|
||||
// The button's title is the action it will perform: "Pause" while playing.
|
||||
const btn = doc.querySelector('#play-pause-button');
|
||||
let isPlaying = false;
|
||||
if (btn) {
|
||||
const label = (btn.getAttribute('title') || btn.getAttribute('aria-label') || '').toLowerCase();
|
||||
isPlaying = label.includes('pause');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
artist: bylineEl ? bylineEl.textContent.trim() : null,
|
||||
albumArt: artImg ? artImg.src : null,
|
||||
isPlaying,
|
||||
};
|
||||
}
|
||||
|
||||
function scrapeNowPlaying(doc, nav) {
|
||||
return fromMediaSession(nav) || fromPlayerBar(doc);
|
||||
}
|
||||
|
||||
module.exports = { scrapeNowPlaying };
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "whats-that-music-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "whats-that-music-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"ws": "^8.20.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "whats-that-music-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "Single-app YouTube Music player + now-playing overlay server for OBS",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -16,25 +16,45 @@
|
||||
"!tests/**"
|
||||
],
|
||||
"extraResources": [
|
||||
{ "from": "../overlay", "to": "overlay" }
|
||||
{
|
||||
"from": "../overlay",
|
||||
"to": "overlay"
|
||||
}
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "index.js"
|
||||
},
|
||||
"win": {
|
||||
"target": "zip",
|
||||
"sign": false,
|
||||
"signAndEditExecutable": false,
|
||||
"certificateSubjectName": null
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage"],
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "AudioVideo",
|
||||
"synopsis": "YouTube Music player with a now-playing overlay for OBS",
|
||||
"maintainer": "What's That Music"
|
||||
},
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "public.app-category.music",
|
||||
"identity": null
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"perMachine": false
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
|
||||
@@ -75,6 +75,9 @@ async function startServer({ port, overlayDir }) {
|
||||
|
||||
function stop() {
|
||||
return new Promise((resolve) => {
|
||||
// wss.close() does not drop existing connections and its callback waits
|
||||
// for every client to disconnect, so a connected overlay would hang quit.
|
||||
wss.clients.forEach((client) => client.terminate());
|
||||
wss.close(() => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { scrapeNowPlaying } = require('../now-playing');
|
||||
|
||||
const emptyDoc = { querySelector: () => null };
|
||||
|
||||
function nav(metadata, playbackState) {
|
||||
return { mediaSession: { metadata, playbackState } };
|
||||
}
|
||||
|
||||
test('reads track info from the Media Session when the player bar is absent', () => {
|
||||
const result = scrapeNowPlaying(
|
||||
emptyDoc,
|
||||
nav(
|
||||
{
|
||||
title: 'DOMINO',
|
||||
artist: 'Stray Kids',
|
||||
album: '',
|
||||
artwork: [
|
||||
{ src: 'small.jpg', sizes: '60x60' },
|
||||
{ src: 'big.jpg', sizes: '544x544' },
|
||||
],
|
||||
},
|
||||
'playing'
|
||||
)
|
||||
);
|
||||
assert.deepStrictEqual(result, {
|
||||
title: 'DOMINO',
|
||||
artist: 'Stray Kids',
|
||||
albumArt: 'big.jpg',
|
||||
isPlaying: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports isPlaying false when the Media Session is paused', () => {
|
||||
const result = scrapeNowPlaying(emptyDoc, nav({ title: 'T', artist: 'A', artwork: [] }, 'paused'));
|
||||
assert.strictEqual(result.isPlaying, false);
|
||||
assert.strictEqual(result.albumArt, null);
|
||||
});
|
||||
|
||||
test('returns null when nothing is loaded anywhere', () => {
|
||||
assert.strictEqual(scrapeNowPlaying(emptyDoc, nav(null, 'none')), null);
|
||||
assert.strictEqual(scrapeNowPlaying(emptyDoc, {}), null);
|
||||
});
|
||||
|
||||
test('falls back to the legacy player-bar DOM when Media Session has no metadata', () => {
|
||||
const els = {
|
||||
'ytmusic-player-bar .title': { textContent: ' Old Song ' },
|
||||
'ytmusic-player-bar .byline': { textContent: 'Old Artist' },
|
||||
'ytmusic-player-bar img.image': { src: 'art.jpg' },
|
||||
'#play-pause-button': { getAttribute: (n) => (n === 'title' ? 'Pause' : null) },
|
||||
};
|
||||
const doc = { querySelector: (s) => els[s] || null };
|
||||
assert.deepStrictEqual(scrapeNowPlaying(doc, nav(null, 'none')), {
|
||||
title: 'Old Song',
|
||||
artist: 'Old Artist',
|
||||
albumArt: 'art.jpg',
|
||||
isPlaying: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const WebSocket = require('ws');
|
||||
const { startServer } = require('../server');
|
||||
|
||||
test('stop() resolves even while an overlay WebSocket client is connected', async () => {
|
||||
const handle = await startServer({ port: 9300 });
|
||||
const client = new WebSocket(`ws://127.0.0.1:${handle.port}`);
|
||||
client.on('error', () => {});
|
||||
await new Promise((resolve) => client.on('open', resolve));
|
||||
|
||||
let timer;
|
||||
const result = await Promise.race([
|
||||
handle.stop().then(() => 'stopped'),
|
||||
new Promise((resolve) => { timer = setTimeout(() => resolve('hung'), 2000); }),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
|
||||
assert.strictEqual(result, 'stopped');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const zlib = require('node:zlib');
|
||||
const { TRAY_ICON_BASE64 } = require('../tray-icon');
|
||||
|
||||
test('tray icon is a structurally valid PNG (every chunk CRC checks out)', () => {
|
||||
const prefix = 'data:image/png;base64,';
|
||||
assert.ok(TRAY_ICON_BASE64.startsWith(prefix));
|
||||
const png = Buffer.from(TRAY_ICON_BASE64.slice(prefix.length), 'base64');
|
||||
assert.strictEqual(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
|
||||
const types = [];
|
||||
let off = 8;
|
||||
while (off < png.length) {
|
||||
const len = png.readUInt32BE(off);
|
||||
const type = png.subarray(off + 4, off + 8);
|
||||
const body = png.subarray(off + 8, off + 8 + len);
|
||||
const crc = png.readUInt32BE(off + 8 + len);
|
||||
assert.strictEqual(zlib.crc32(Buffer.concat([type, body])), crc, `bad CRC in ${type}`);
|
||||
types.push(type.toString());
|
||||
off += 12 + len;
|
||||
}
|
||||
assert.strictEqual(off, png.length, 'trailing bytes after last chunk');
|
||||
assert.deepStrictEqual([types[0], types.at(-1)], ['IHDR', 'IEND']);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// 32x32 green circle (RGBA PNG) used for the tray icon.
|
||||
const TRAY_ICON_BASE64 =
|
||||
'data:image/png;base64,' +
|
||||
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAt0lEQVR42u2XzQ2A' +
|
||||
'IAxGGYFJPLID47gDQ7BNF3AQhiBRDjUxJlZQSnvg8C4G8j1+hGKWbTWSmCnQ2MEV' +
|
||||
'QgEKqZCRhN8Ctuku4DFgrwSwTxeB2BB8J/4RsI2jpmbDfhHoEX6VaBKIHcPJ5Xja' +
|
||||
'cDsTvkYAGAXgTcAxhp84SiAMEAiUAAwQAEogDRBIlEAeIJBVC4gvgfgmFP8NxQ8i' +
|
||||
'8aNYxWUkfh2rKEhUlGTiRamaslzFw2S+Ddk4AA0mvKa9jODdAAAAAElFTkSuQmCC';
|
||||
|
||||
module.exports = { TRAY_ICON_BASE64 };
|
||||
+1
-14
@@ -1,18 +1,5 @@
|
||||
const { Tray, Menu, nativeImage, shell, clipboard } = require('electron');
|
||||
|
||||
// Minimal green-circle tray icon, kept from the previous version for visual continuity.
|
||||
const TRAY_ICON_BASE64 =
|
||||
'data:image/png;base64,' +
|
||||
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAa' +
|
||||
'RJREFUWEftlj1OxDAQhe0TcARuABUNDQUF4gRIFJyAG9BQcQJOwA1oKGgoOAI3wP' +
|
||||
'vkWRnZjr1xsgVilCiy4/n85s14nMGO/wY79v/dAfwH4T8IpUNoSj9FxAPiS+IHRO' +
|
||||
'nzEscvUz4gnkV8JF5C7BNfEe8j6pznxFvEp8RviM8RfyXOlxPVd0j5QAgOBaET5E' +
|
||||
'xgXxBPIS5GzlMEMxIJ3kLwPPFT4gPEudC+ILYimlkuJKovoeSBaEuCg8CTiHPC0r' +
|
||||
'sQnETM7Q7HqRdS70ygIHYb4p/Ee4hziT0cDx23BaGMwA3EF4nXEi8iz6fOF4Tae+' +
|
||||
'LniOuJsxN5PxZ+JgTfIL5K7E4cJIiScr5LjA7lPmvuP0/ETvAEvQn4Tqy+HNNyTk' +
|
||||
'fiWeT/0niDe4kqEkX5e5bOGwmhOsRsN2UEQ/qvY/ISxOuIq4k7tI2EcJ38NcT3iX' +
|
||||
'PB7S4/EuoISgYrLKD1dOhOPFP6+4R7ieoQPNv9/ULZ+zfis+0Y1e+F7oF3H5F+Xt' +
|
||||
'T5p0AqBYeEeEF8k/gtcS56z6f/E5UOLHL8A8a4yC0IHbH4AAAAASUVORK5CYII=';
|
||||
const { TRAY_ICON_BASE64 } = require('./tray-icon');
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
|
||||
@@ -1,42 +1,9 @@
|
||||
const { ipcRenderer } = require('electron');
|
||||
const { scrapeNowPlaying } = require('./now-playing');
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
let lastSent = null;
|
||||
|
||||
function scrapeNowPlaying() {
|
||||
const titleEl =
|
||||
document.querySelector('ytmusic-player-bar .title') ||
|
||||
document.querySelector('.ytmusic-player-bar .title');
|
||||
const title = titleEl ? titleEl.textContent.trim() : null;
|
||||
|
||||
const bylineEl =
|
||||
document.querySelector('ytmusic-player-bar .byline') ||
|
||||
document.querySelector('.ytmusic-player-bar .byline');
|
||||
const artist = bylineEl ? bylineEl.textContent.trim() : null;
|
||||
|
||||
const artImg =
|
||||
document.querySelector('ytmusic-player-bar .middle-controls .thumbnail-image-wrapper img') ||
|
||||
document.querySelector('ytmusic-player-bar .image img') ||
|
||||
document.querySelector('ytmusic-player-bar img.image');
|
||||
const albumArt = artImg ? artImg.src : null;
|
||||
|
||||
// The play/pause button's title indicates state: when music is playing the
|
||||
// button's action is "Pause", and vice versa.
|
||||
const playPauseBtn = document.querySelector('#play-pause-button');
|
||||
let isPlaying = false;
|
||||
if (playPauseBtn) {
|
||||
const btnTitle = (
|
||||
playPauseBtn.getAttribute('title') ||
|
||||
playPauseBtn.getAttribute('aria-label') ||
|
||||
''
|
||||
).toLowerCase();
|
||||
isPlaying = btnTitle.includes('pause');
|
||||
}
|
||||
|
||||
if (!title) return null;
|
||||
return { title, artist, albumArt, isPlaying };
|
||||
}
|
||||
|
||||
function changed(a, b) {
|
||||
if (a === null && b === null) return false;
|
||||
if (a === null || b === null) return true;
|
||||
@@ -49,7 +16,7 @@ function changed(a, b) {
|
||||
}
|
||||
|
||||
function poll() {
|
||||
const data = scrapeNowPlaying();
|
||||
const data = scrapeNowPlaying(document, navigator);
|
||||
if (!changed(data, lastSent)) return;
|
||||
lastSent = data;
|
||||
ipcRenderer.send('wtm:state', data || { isPlaying: false });
|
||||
|
||||
Reference in New Issue
Block a user