mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
854 lines
24 KiB
TypeScript
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')
|
|
}
|
|
}
|