Files
prism/src/main/services/astraIntegration.ts
T
2026-04-12 20:21:18 -04:00

854 lines
24 KiB
TypeScript

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import type { NowPlayingProviderState } from '../../types/nowPlaying'
import {
DEFAULT_ASTRA_BASE_URL,
type AstraControlCommand,
type AstraIntegrationConfig,
type AstraIntegrationConfigMutation,
type AstraIntegrationPublicConfig,
type AstraIntegrationState,
type AstraNowPlayingSnapshot,
type AstraPlaybackState,
type AstraTrackSnapshot,
} from '../../types/astra'
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000]
const DEFAULT_ASTRA_SECRET_KEY = 'now-playing.astra.token'
type FetchLike = typeof fetch
type TimerHandle = ReturnType<typeof setTimeout>
interface SecretVaultLike {
deleteSecret(key: string): Promise<void>
getSecret(key: string): Promise<string | null>
setSecret(key: string, value: string): Promise<void>
}
interface RemoteTrackSnapshot {
id: string
title: string
artist: string
album: string
isFavorite: boolean
artworkUrl: string | null
}
interface RemoteNowPlayingSnapshot {
playbackState: AstraPlaybackState
currentTime: number
duration: number
queueLength: number
outputDeviceLabel: string | null
visualizerLineColor: string
currentTrack: RemoteTrackSnapshot | null
updatedAt: number
}
interface AstraIntegrationServiceOptions {
configPath: string
secretKey?: string
secretVault: SecretVaultLike
fetchImpl?: FetchLike
now?: () => number
setTimeoutImpl?: typeof setTimeout
clearTimeoutImpl?: typeof clearTimeout
}
interface PersistedAstraConfig {
baseUrl: string
token: string
}
function cloneTrackSnapshot(track: AstraTrackSnapshot | null): AstraTrackSnapshot | null {
if (!track) return null
return { ...track }
}
function cloneSnapshot(snapshot: AstraNowPlayingSnapshot | null): AstraNowPlayingSnapshot | null {
if (!snapshot) return null
return {
...snapshot,
currentTrack: cloneTrackSnapshot(snapshot.currentTrack),
}
}
function cloneState(state: AstraIntegrationState): AstraIntegrationState {
return {
config: { ...state.config },
connectionState: state.connectionState,
lastError: state.lastError,
lastControlError: state.lastControlError,
snapshot: cloneSnapshot(state.snapshot),
}
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
: fallback
}
function isPlaybackState(value: unknown): value is AstraPlaybackState {
return value === 'stopped' || value === 'playing' || value === 'paused' || value === 'loading'
}
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
return Math.max(0, value)
}
function toSafeString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
function toOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized.length > 0 ? normalized : null
}
function bufferToDataUrl(bytes: Buffer, mimeType: string): string {
return `data:${mimeType};base64,${bytes.toString('base64')}`
}
function getArtworkKey(trackId: string | null, artworkUrl: string | null): string | null {
if (!trackId || !artworkUrl) return null
return `${trackId}\n${artworkUrl}`
}
function normalizeBaseUrl(value: unknown): string {
if (typeof value !== 'string') return DEFAULT_ASTRA_BASE_URL
const trimmed = value.trim()
if (!trimmed) return DEFAULT_ASTRA_BASE_URL
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
export function normalizeAstraIntegrationConfig(raw: unknown): AstraIntegrationConfig {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<AstraIntegrationConfig>
: {}
return {
baseUrl: normalizeBaseUrl(parsed.baseUrl),
token: typeof parsed.token === 'string' ? parsed.token.trim() : '',
}
}
export function normalizeAstraIntegrationConfigMutation(raw: unknown): AstraIntegrationConfigMutation {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<AstraIntegrationConfigMutation>
: {}
const normalizedToken = typeof parsed.token === 'string'
? parsed.token.trim()
: ''
return {
baseUrl: normalizeBaseUrl(parsed.baseUrl),
token: normalizedToken.length > 0 ? normalizedToken : undefined,
clearToken: parsed.clearToken === true,
}
}
function toPublicConfig(config: PersistedAstraConfig): AstraIntegrationPublicConfig {
return {
baseUrl: config.baseUrl,
hasToken: config.token.trim().length > 0,
}
}
function createDefaultRuntimeConfig(): PersistedAstraConfig {
return {
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: '',
}
}
function createDefaultState(): AstraIntegrationState {
return {
config: toPublicConfig(createDefaultRuntimeConfig()),
connectionState: 'disabled',
lastError: null,
lastControlError: null,
snapshot: null,
}
}
function normalizeRemoteTrack(raw: unknown): RemoteTrackSnapshot | null {
if (typeof raw !== 'object' || raw === null) return null
const parsed = raw as Partial<RemoteTrackSnapshot>
const id = toOptionalString(parsed.id)
const title = toSafeString(parsed.title).trim()
const artist = toSafeString(parsed.artist).trim()
if (!id || !title || !artist) {
return null
}
return {
id,
title,
artist,
album: toSafeString(parsed.album).trim(),
isFavorite: Boolean(parsed.isFavorite),
artworkUrl: toOptionalString(parsed.artworkUrl),
}
}
function normalizeRemoteSnapshot(raw: unknown, now: () => number): RemoteNowPlayingSnapshot {
if (typeof raw !== 'object' || raw === null) {
return {
playbackState: 'stopped',
currentTime: 0,
duration: 0,
queueLength: 0,
outputDeviceLabel: null,
visualizerLineColor: '#38bdf8',
currentTrack: null,
updatedAt: now(),
}
}
const parsed = raw as Partial<RemoteNowPlayingSnapshot>
return {
playbackState: isPlaybackState(parsed.playbackState) ? parsed.playbackState : 'stopped',
currentTime: toSafeNumber(parsed.currentTime),
duration: toSafeNumber(parsed.duration),
queueLength: Math.max(0, Math.floor(toSafeNumber(parsed.queueLength))),
outputDeviceLabel: toOptionalString(parsed.outputDeviceLabel),
visualizerLineColor: toOptionalString(parsed.visualizerLineColor) ?? '#38bdf8',
currentTrack: normalizeRemoteTrack(parsed.currentTrack),
updatedAt: typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt)
? parsed.updatedAt
: now(),
}
}
function toRendererSnapshot(
snapshot: RemoteNowPlayingSnapshot,
artworkDataUrl: string | null,
): AstraNowPlayingSnapshot {
const currentTrack = snapshot.currentTrack
return {
playbackState: snapshot.playbackState,
currentTime: snapshot.currentTime,
duration: snapshot.duration,
queueLength: snapshot.queueLength,
outputDeviceLabel: snapshot.outputDeviceLabel,
visualizerLineColor: snapshot.visualizerLineColor,
currentTrack: currentTrack
? {
id: currentTrack.id,
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
isFavorite: currentTrack.isFavorite,
artworkDataUrl,
} satisfies AstraTrackSnapshot
: null,
updatedAt: snapshot.updatedAt,
}
}
async function readResponseError(response: Response): Promise<string> {
try {
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
const parsed = await response.json() as { error?: unknown }
const errorMessage = toOptionalString(parsed.error)
if (errorMessage) return errorMessage
}
const text = (await response.text()).trim()
if (text) return text
} catch {
// Ignore parse failures.
}
return `Request failed with status ${response.status}.`
}
function appendBasePath(baseUrl: string, path: string): string {
const normalizedBase = baseUrl.endsWith('/') ? `${baseUrl}` : `${baseUrl}/`
return new URL(path.replace(/^\//, ''), normalizedBase).toString()
}
export class AstraIntegrationService {
readonly providerId = 'astra'
private readonly configPath: string
private readonly secretKey: string
private readonly secretVault: SecretVaultLike
private readonly fetchImpl: FetchLike
private readonly now: () => number
private readonly setTimeoutImpl: typeof setTimeout
private readonly clearTimeoutImpl: typeof clearTimeout
private readonly listeners = new Set<(state: AstraIntegrationState) => void>()
private readonly activeConsumers = new Set<number>()
private config = createDefaultRuntimeConfig()
private state = createDefaultState()
private remoteSnapshot: RemoteNowPlayingSnapshot | null = null
private currentArtworkKey: string | null = null
private currentArtworkDataUrl: string | null = null
private failedArtworkKey: string | null = null
private streamAbortController: AbortController | null = null
private reconnectTimer: TimerHandle | null = null
private reconnectAttempt = 0
private initialized = false
private disposed = false
constructor(options: AstraIntegrationServiceOptions) {
this.configPath = options.configPath
this.secretKey = options.secretKey ?? DEFAULT_ASTRA_SECRET_KEY
this.secretVault = options.secretVault
this.fetchImpl = options.fetchImpl ?? fetch
this.now = options.now ?? (() => Date.now())
this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout
this.clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout
}
async initialize(): Promise<void> {
const { config, migrationError } = await this.loadConfigFile()
this.initialized = true
this.config = config
this.state = {
...this.state,
config: toPublicConfig(config),
connectionState: 'disabled',
lastError: migrationError,
lastControlError: null,
snapshot: null,
}
this.emitState()
if (this.isScopeActive()) {
await this.restartConnection()
}
}
async dispose(): Promise<void> {
this.disposed = true
this.cancelReconnect()
this.abortStream()
}
subscribe(listener: (state: AstraIntegrationState) => void): () => void {
this.listeners.add(listener)
listener(this.getState())
return () => {
this.listeners.delete(listener)
}
}
getState(): AstraIntegrationState {
return cloneState(this.state)
}
getPublicConfig(): AstraIntegrationPublicConfig {
return { ...this.state.config }
}
getProviderState(): NowPlayingProviderState {
return {
providerId: 'astra',
connectionState: this.state.connectionState,
lastError: this.state.lastError,
lastControlError: this.state.lastControlError,
snapshot: cloneSnapshot(this.state.snapshot),
isConfigured: this.state.config.hasToken,
available: true,
supportsTransportControls: true,
}
}
async setConsumerActive(consumerId: number, active: boolean): Promise<AstraIntegrationState> {
const wasActive = this.isScopeActive()
if (active) {
this.activeConsumers.add(consumerId)
} else {
this.activeConsumers.delete(consumerId)
}
if (!this.initialized) {
return this.getState()
}
if (wasActive !== this.isScopeActive()) {
await this.restartConnection()
}
return this.getState()
}
async saveConfig(rawConfig: unknown): Promise<AstraIntegrationPublicConfig> {
const configMutation = normalizeAstraIntegrationConfigMutation(rawConfig)
let secretError: Error | null = null
this.config = {
...this.config,
baseUrl: configMutation.baseUrl,
}
await this.persistConfigFile(this.config)
try {
if (configMutation.clearToken) {
await this.secretVault.deleteSecret(this.secretKey)
this.config = {
...this.config,
token: '',
}
} else if (configMutation.token) {
await this.secretVault.setSecret(this.secretKey, configMutation.token)
this.config = {
...this.config,
token: configMutation.token,
}
}
} catch (error) {
secretError = new Error(getErrorMessage(error, 'Prism could not save the Astra token securely.'))
}
this.state = {
...this.state,
config: toPublicConfig(this.config),
connectionState: this.isScopeActive() ? 'connecting' : 'disabled',
lastError: null,
lastControlError: null,
snapshot: null,
}
this.emitState()
await this.restartConnection()
if (secretError) {
throw secretError
}
return this.getPublicConfig()
}
async retry(): Promise<void> {
await this.restartConnection()
}
async sendControl(command: AstraControlCommand): Promise<void> {
if (!this.isScopeActive()) {
const errorMessage = 'The Astra scope is not open.'
this.state = {
...this.state,
lastControlError: errorMessage,
}
this.emitState()
throw new Error(errorMessage)
}
if (!this.config.token) {
const errorMessage = 'An Astra API token is required before sending controls.'
this.state = {
...this.state,
lastControlError: errorMessage,
}
this.emitState()
throw new Error(errorMessage)
}
const response = await this.fetchImpl(this.buildEndpoint('/v1/control'), {
method: 'POST',
headers: this.buildAuthHeaders({
'Content-Type': 'application/json; charset=utf-8',
}),
body: JSON.stringify({ command }),
}).catch((error: unknown) => {
throw new Error(getErrorMessage(error, 'Prism could not reach Astra.'))
})
if (!response.ok) {
const errorMessage = await readResponseError(response)
this.state = {
...this.state,
lastControlError: errorMessage,
}
this.emitState()
throw new Error(errorMessage)
}
this.state = {
...this.state,
lastControlError: null,
}
this.emitState()
}
private emitState(): void {
const snapshot = this.getState()
for (const listener of this.listeners) {
listener(snapshot)
}
}
private async restartConnection(): Promise<void> {
this.cancelReconnect()
this.abortStream()
if (!this.isScopeActive() || this.disposed) {
this.remoteSnapshot = null
this.currentArtworkKey = null
this.currentArtworkDataUrl = null
this.failedArtworkKey = null
this.state = {
...this.state,
connectionState: 'disabled',
lastError: null,
lastControlError: null,
snapshot: null,
}
this.emitState()
return
}
if (!this.config.token) {
this.state = {
...this.state,
connectionState: 'error',
lastError: 'An Astra API token is required.',
}
this.emitState()
return
}
this.state = {
...this.state,
connectionState: 'connecting',
lastError: null,
snapshot: null,
}
this.failedArtworkKey = null
this.emitState()
try {
await this.fetchNowPlaying()
} catch (error) {
this.state = {
...this.state,
connectionState: 'error',
lastError: getErrorMessage(error, 'Prism could not read Astra now-playing state.'),
}
this.emitState()
}
if (!this.isScopeActive() || this.disposed) {
return
}
void this.openEventStream()
}
private scheduleReconnect(message: string): void {
if (!this.isScopeActive() || this.disposed) {
return
}
this.state = {
...this.state,
connectionState: 'error',
lastError: message,
}
this.emitState()
const delay = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? 10000
this.reconnectAttempt += 1
this.cancelReconnect()
this.reconnectTimer = this.setTimeoutImpl(() => {
this.reconnectTimer = null
void this.restartConnection()
}, delay)
}
private cancelReconnect(): void {
if (this.reconnectTimer === null) return
this.clearTimeoutImpl(this.reconnectTimer)
this.reconnectTimer = null
}
private abortStream(): void {
if (this.streamAbortController) {
this.streamAbortController.abort()
this.streamAbortController = null
}
}
private async fetchNowPlaying(): Promise<void> {
const response = await this.fetchImpl(this.buildEndpoint('/v1/now-playing'), {
headers: this.buildAuthHeaders(),
}).catch((error: unknown) => {
throw new Error(getErrorMessage(error, 'Prism could not reach Astra.'))
})
if (!response.ok) {
throw new Error(await readResponseError(response))
}
const payload = await response.json().catch(() => null)
const snapshot = normalizeRemoteSnapshot(payload, this.now)
this.applyRemoteSnapshot(snapshot)
await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null)
}
private async openEventStream(): Promise<void> {
this.abortStream()
const controller = new AbortController()
this.streamAbortController = controller
try {
const response = await this.fetchImpl(this.buildEndpoint('/v1/events'), {
headers: this.buildAuthHeaders({
Accept: 'text/event-stream',
}),
signal: controller.signal,
})
if (!response.ok) {
throw new Error(await readResponseError(response))
}
if (!response.body) {
throw new Error('Astra did not return an event stream.')
}
this.reconnectAttempt = 0
this.state = {
...this.state,
connectionState: 'connected',
lastError: null,
}
this.emitState()
await this.readEventStream(response.body, controller.signal)
if (!controller.signal.aborted && this.isScopeActive() && !this.disposed) {
this.scheduleReconnect('The Astra event stream closed unexpectedly.')
}
} catch (error) {
if (controller.signal.aborted || this.disposed) {
return
}
this.scheduleReconnect(getErrorMessage(error, 'Prism could not connect to the Astra event stream.'))
}
}
private async readEventStream(
stream: ReadableStream<Uint8Array>,
signal: AbortSignal,
): Promise<void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let eventName = 'message'
let dataLines: string[] = []
const flushEvent = async (): Promise<void> => {
if (dataLines.length === 0) {
eventName = 'message'
return
}
const payload = dataLines.join('\n')
const nextEvent = eventName
eventName = 'message'
dataLines = []
if (nextEvent === 'now-playing') {
let parsed: unknown = null
try {
parsed = JSON.parse(payload)
} catch {
return
}
const snapshot = normalizeRemoteSnapshot(parsed, this.now)
this.applyRemoteSnapshot(snapshot)
await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null)
}
}
while (!signal.aborted) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '')
buffer = buffer.slice(newlineIndex + 1)
if (line === '') {
await flushEvent()
} else if (line.startsWith(':')) {
// Ignore SSE comments.
} else if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message'
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
newlineIndex = buffer.indexOf('\n')
}
}
if (buffer.trim().length > 0) {
const line = buffer.replace(/\r$/, '')
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
await flushEvent()
await reader.cancel().catch(() => undefined)
}
private applyRemoteSnapshot(snapshot: RemoteNowPlayingSnapshot): void {
this.remoteSnapshot = snapshot
const artworkDataUrl = getArtworkKey(
snapshot.currentTrack?.id ?? null,
snapshot.currentTrack?.artworkUrl ?? null,
) === this.currentArtworkKey
? this.currentArtworkDataUrl
: null
this.state = {
...this.state,
snapshot: toRendererSnapshot(snapshot, artworkDataUrl),
}
this.emitState()
}
private async refreshArtwork(trackId: string | null, artworkUrl: string | null): Promise<void> {
const artworkKey = getArtworkKey(trackId, artworkUrl)
const remoteArtworkKey = getArtworkKey(
this.remoteSnapshot?.currentTrack?.id ?? null,
this.remoteSnapshot?.currentTrack?.artworkUrl ?? null,
)
if (!artworkKey || remoteArtworkKey !== artworkKey) {
this.currentArtworkKey = null
this.currentArtworkDataUrl = null
this.failedArtworkKey = null
return
}
if (this.currentArtworkKey === artworkKey && this.currentArtworkDataUrl) {
return
}
if (this.failedArtworkKey === artworkKey) {
return
}
this.currentArtworkKey = null
this.currentArtworkDataUrl = null
if (artworkUrl === null) {
return
}
const resolvedArtworkUrl = artworkUrl
const response = await this.fetchImpl(resolvedArtworkUrl, {
headers: this.buildAuthHeaders(),
}).catch(() => null)
if (!response || !response.ok) {
this.failedArtworkKey = artworkKey
return
}
const mimeType = response.headers.get('content-type') ?? 'image/png'
const bytes = Buffer.from(await response.arrayBuffer())
const artworkDataUrl = bufferToDataUrl(bytes, mimeType)
const remoteSnapshot = this.remoteSnapshot
if (!remoteSnapshot || getArtworkKey(
remoteSnapshot.currentTrack?.id ?? null,
remoteSnapshot.currentTrack?.artworkUrl ?? null,
) !== artworkKey) {
return
}
this.currentArtworkKey = artworkKey
this.currentArtworkDataUrl = artworkDataUrl
this.failedArtworkKey = null
this.state = {
...this.state,
snapshot: toRendererSnapshot(remoteSnapshot, artworkDataUrl),
}
this.emitState()
}
private buildAuthHeaders(extraHeaders?: Record<string, string>): HeadersInit {
return {
Authorization: `Bearer ${this.config.token}`,
...extraHeaders,
}
}
private isScopeActive(): boolean {
return this.activeConsumers.size > 0
}
private buildEndpoint(path: string): string {
return appendBasePath(this.config.baseUrl, path)
}
private async loadConfigFile(): Promise<{
config: PersistedAstraConfig
migrationError: string | null
}> {
let persistedConfig = createDefaultRuntimeConfig()
try {
const raw = await readFile(this.configPath, 'utf8')
persistedConfig = normalizeAstraIntegrationConfig(JSON.parse(raw))
} catch {
persistedConfig = createDefaultRuntimeConfig()
}
let token = ''
let migrationError: string | null = null
if (persistedConfig.token) {
try {
await this.secretVault.setSecret(this.secretKey, persistedConfig.token)
token = persistedConfig.token
} catch (error) {
migrationError = getErrorMessage(error, 'Prism could not migrate the Astra token into secure storage.')
}
persistedConfig = {
...persistedConfig,
token: '',
}
await this.persistConfigFile(persistedConfig)
} else {
try {
token = await this.secretVault.getSecret(this.secretKey) ?? ''
} catch (error) {
migrationError = getErrorMessage(error, 'Prism could not read the stored Astra token.')
}
}
return {
config: {
baseUrl: persistedConfig.baseUrl,
token,
},
migrationError,
}
}
private async persistConfigFile(config: PersistedAstraConfig): Promise<void> {
await mkdir(dirname(this.configPath), { recursive: true })
await writeFile(this.configPath, `${JSON.stringify({ baseUrl: config.baseUrl }, null, 2)}\n`, 'utf8')
}
}