mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-17 19:24:03 +02:00
update session hooks on linux
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user