update session hooks on linux

This commit is contained in:
Boof2015
2026-04-19 00:10:33 -04:00
parent 4324fdaf41
commit fa67547f01
5 changed files with 844 additions and 50 deletions
+198 -9
View File
@@ -29,6 +29,45 @@ function createStatusPayload(options: {
].join(DELIMITER)
}
function createLinuxListNamesPayload(names: string[]): string {
return `([${names.map((name) => `'${name}'`).join(', ')}],)`
}
function createLinuxStatusPayload(options: {
album?: string
artist?: string
artworkUrl?: string
durationUs?: number
playbackState: 'Playing' | 'Paused' | 'Stopped'
positionUs?: number
title?: string
trackId?: string
trackUrl?: string
}): string {
const artists = options.artist ? `['${options.artist}']` : '[]'
return `({'PlaybackStatus': <'${options.playbackState}'>, 'Metadata': <{'mpris:trackid': <objectpath '${options.trackId ?? '/com/spotify/track/123'}'>, 'mpris:length': <int64 ${options.durationUs ?? 0}>, 'mpris:artUrl': <'${options.artworkUrl ?? ''}'>, 'xesam:album': <'${options.album ?? ''}'>, 'xesam:artist': <${artists}>, 'xesam:title': <'${options.title ?? ''}'>, 'xesam:url': <'${options.trackUrl ?? ''}'>}>, 'Position': <int64 ${options.positionUs ?? 0}>},)`
}
function createWindowsStatusPayload(options: {
album?: string
artist?: string
durationMs?: number
playbackStatus: 'Playing' | 'Paused' | 'Stopped'
positionMs?: number
sourceAppUserModelId?: string
title?: string
}): string {
return JSON.stringify({
album: options.album ?? '',
artist: options.artist ?? '',
durationMs: options.durationMs ?? 0,
playbackStatus: options.playbackStatus,
positionMs: options.positionMs ?? 0,
sourceAppUserModelId: options.sourceAppUserModelId ?? 'SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify',
title: options.title ?? '',
})
}
async function waitFor(predicate: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 2000
while (Date.now() < deadline) {
@@ -81,13 +120,26 @@ class StubSpotifyRunner {
}
}
test('provider stays unavailable when Spotify.app is not installed or not on macOS', async () => {
class StubCommandRunner {
calls: Array<{ command: string; args: string[] }> = []
error: Error | null = null
constructor(
private readonly handler: (command: string, args: string[]) => string,
) {}
async run(command: string, args: string[]): Promise<string> {
this.calls.push({ command, args: [...args] })
if (this.error) {
throw this.error
}
return this.handler(command, args)
}
}
test('provider stays unavailable on unsupported platforms', async () => {
const provider = new MacSpotifyProvider({
accessImpl: async () => {
throw new Error('missing')
},
platform: 'linux',
runner: async () => 'not_running',
platform: 'freebsd',
})
try {
@@ -99,7 +151,7 @@ test('provider stays unavailable when Spotify.app is not installed or not on mac
}
})
test('provider reads local Spotify playback and hydrates artwork while active', async () => {
test('provider reads local macOS Spotify playback and hydrates artwork while active', async () => {
const runner = new StubSpotifyRunner([
createStatusPayload({
playbackState: 'playing',
@@ -144,7 +196,7 @@ test('provider reads local Spotify playback and hydrates artwork while active',
}
})
test('provider treats a non-running Spotify app as idle instead of erroring', async () => {
test('provider treats a non-running macOS Spotify app as idle instead of erroring', async () => {
const provider = new MacSpotifyProvider({
accessImpl: async () => undefined,
platform: 'darwin',
@@ -180,7 +232,7 @@ test('provider surfaces macOS Automation permission failures during polling', as
}
})
test('provider routes transport controls through AppleScript and records control failures', async () => {
test('provider routes macOS transport controls through AppleScript and records control failures', async () => {
const runner = new StubSpotifyRunner([
createStatusPayload({
playbackState: 'paused',
@@ -210,3 +262,140 @@ test('provider routes transport controls through AppleScript and records control
await provider.dispose()
}
})
test('provider reads Linux Spotify playback through MPRIS and routes controls through gdbus', async () => {
const runner = new StubCommandRunner((command, args) => {
assert.equal(command, 'gdbus')
const methodIndex = args.indexOf('--method')
const method = methodIndex >= 0 ? args[methodIndex + 1] : ''
switch (method) {
case 'org.freedesktop.DBus.ListNames':
return createLinuxListNamesPayload([
'org.freedesktop.DBus',
'org.mpris.MediaPlayer2.spotify',
])
case 'org.freedesktop.DBus.Properties.GetAll':
return createLinuxStatusPayload({
playbackState: 'Playing',
positionUs: 42000000,
durationUs: 180000000,
title: 'Song Linux',
artist: 'Artist Linux',
album: 'Album Linux',
artworkUrl: 'https://i.scdn.co/image/linux-cover',
trackUrl: 'spotify:track:linux123',
})
case 'org.mpris.MediaPlayer2.Player.Next':
return '()'
default:
throw new Error(`Unhandled Linux method ${method}`)
}
})
const provider = new MacSpotifyProvider({
commandRunner: (command, args) => runner.run(command, args),
fetchImpl: async () => new Response(Buffer.from('linux-cover'), {
status: 200,
headers: {
'content-type': 'image/png',
},
}),
now: () => 2000,
platform: 'linux',
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, true)
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Linux Spotify state')
const state = provider.getProviderState()
assert.equal(state.snapshot?.currentTrack?.title, 'Song Linux')
assert.equal(state.snapshot?.currentTrack?.artist, 'Artist Linux')
assert.equal(state.snapshot?.currentTrack?.isFavorite, false)
assert.match(state.snapshot?.currentTrack?.artworkDataUrl ?? '', /^data:image\/png;base64,/)
assert.equal(state.snapshot?.currentTime, 42)
assert.equal(state.snapshot?.duration, 180)
await provider.sendControl('next')
assert.ok(runner.calls.some((call) => call.args.includes('org.mpris.MediaPlayer2.Player.Next')))
} finally {
await provider.dispose()
}
})
test('provider treats a missing Linux Spotify MPRIS session as idle', async () => {
const provider = new MacSpotifyProvider({
commandRunner: async (_command, args) => {
if (args.includes('org.freedesktop.DBus.ListNames')) {
return createLinuxListNamesPayload(['org.freedesktop.DBus'])
}
throw new Error('unexpected command')
},
platform: 'linux',
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, true)
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'disabled', 'expected disabled Linux Spotify state')
assert.equal(provider.getProviderState().lastError, null)
} finally {
await provider.dispose()
}
})
test('provider reads Windows Spotify playback through system media controls and routes commands', async () => {
const runner = new StubCommandRunner((command, args) => {
assert.equal(command, 'powershell.exe')
const script = args.at(-1) ?? ''
if (script.includes('Write-Output "ready"')) {
return 'ready'
}
if (script.includes('ConvertTo-Json')) {
return createWindowsStatusPayload({
playbackStatus: 'Playing',
positionMs: 32000,
durationMs: 210000,
title: 'Song Windows',
artist: 'Artist Windows',
album: 'Album Windows',
})
}
if (script.includes('TrySkipNextAsync')) {
return 'ok'
}
throw new Error('unexpected powershell script')
})
const provider = new MacSpotifyProvider({
commandRunner: (command, args) => runner.run(command, args),
now: () => 3000,
platform: 'win32',
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, true)
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Windows Spotify state')
const state = provider.getProviderState()
assert.equal(state.snapshot?.currentTrack?.title, 'Song Windows')
assert.equal(state.snapshot?.currentTrack?.artist, 'Artist Windows')
assert.equal(state.snapshot?.currentTrack?.isFavorite, false)
assert.equal(state.snapshot?.currentTime, 32)
assert.equal(state.snapshot?.duration, 210)
await provider.sendControl('next')
assert.ok(runner.calls.some((call) => (call.args.at(-1) ?? '').includes('TrySkipNextAsync')))
} finally {
await provider.dispose()
}
})