Move project from bordanlage/ to repo root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
36
dashboard/src/api/jellyfin.js
Normal file
36
dashboard/src/api/jellyfin.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// Jellyfin REST API client.
|
||||
|
||||
export function createJellyfinClient(baseUrl, apiKey) {
|
||||
const headers = {
|
||||
'Authorization': `MediaBrowser Token="${apiKey}", Client="Bordanlage", Device="Dashboard", DeviceId="bordanlage-1", Version="1.0"`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const res = await fetch(`${baseUrl}${path}`, { headers })
|
||||
if (!res.ok) throw new Error(`Jellyfin ${res.status}: ${path}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
async getArtists() {
|
||||
return get('/Artists?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Audio&Recursive=true')
|
||||
},
|
||||
async getAlbums(artistId) {
|
||||
const q = artistId ? `&ArtistIds=${artistId}` : ''
|
||||
return get(`/Items?SortBy=SortName&IncludeItemTypes=MusicAlbum&Recursive=true${q}`)
|
||||
},
|
||||
async getTracks(albumId) {
|
||||
return get(`/Items?ParentId=${albumId}&IncludeItemTypes=Audio&SortBy=IndexNumber`)
|
||||
},
|
||||
async search(query) {
|
||||
return get(`/Items?SearchTerm=${encodeURIComponent(query)}&IncludeItemTypes=Audio,MusicAlbum,MusicArtist&Recursive=true&Limit=20`)
|
||||
},
|
||||
getStreamUrl(itemId) {
|
||||
return `${baseUrl}/Audio/${itemId}/stream?static=true&api_key=${apiKey}`
|
||||
},
|
||||
getImageUrl(itemId, type = 'Primary') {
|
||||
return `${baseUrl}/Items/${itemId}/Images/${type}?api_key=${apiKey}`
|
||||
}
|
||||
}
|
||||
}
|
||||
84
dashboard/src/api/mopidy.js
Normal file
84
dashboard/src/api/mopidy.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Real Mopidy JSON-RPC WebSocket client.
|
||||
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export function createMopidyClient(baseUrl) {
|
||||
const wsUrl = `${baseUrl}/mopidy/ws`
|
||||
let ws = null
|
||||
let reconnectAttempt = 0
|
||||
let destroyed = false
|
||||
let msgId = 1
|
||||
const pending = new Map()
|
||||
const listeners = {}
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
emit('connected', null)
|
||||
}
|
||||
|
||||
ws.onmessage = ({ data }) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
|
||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(msg.error.message))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
|
||||
// Mopidy events (no id)
|
||||
if (msg.event) emit(`event:${msg.event}`, msg)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
emit('disconnected', null)
|
||||
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
|
||||
pending.clear()
|
||||
if (!destroyed) scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||
reconnectAttempt++
|
||||
setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return {
|
||||
call(method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return reject(new Error('Mopidy not connected'))
|
||||
}
|
||||
const id = msgId++
|
||||
pending.set(id, { resolve, reject })
|
||||
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
|
||||
})
|
||||
},
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
disconnect() {
|
||||
destroyed = true
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
82
dashboard/src/api/signalk.js
Normal file
82
dashboard/src/api/signalk.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Real SignalK WebSocket client with reconnect.
|
||||
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export function createSignalKClient(baseUrl) {
|
||||
const wsUrl = `${baseUrl}/signalk/v1/stream?subscribe=self`
|
||||
const listeners = {}
|
||||
let ws = null
|
||||
let reconnectAttempt = 0
|
||||
let destroyed = false
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
emit('connected', null)
|
||||
|
||||
// Subscribe to relevant paths
|
||||
ws.send(JSON.stringify({
|
||||
context: 'vessels.self',
|
||||
subscribe: [
|
||||
{ path: 'navigation.speedOverGround' },
|
||||
{ path: 'navigation.courseOverGroundTrue' },
|
||||
{ path: 'navigation.headingTrue' },
|
||||
{ path: 'navigation.position' },
|
||||
{ path: 'environment.depth.belowKeel' },
|
||||
{ path: 'environment.wind.speedApparent' },
|
||||
{ path: 'environment.wind.angleApparent' },
|
||||
{ path: 'environment.water.temperature' },
|
||||
{ path: 'environment.outside.temperature' },
|
||||
{ path: 'propulsion.main.revolutions' },
|
||||
{ path: 'electrical.batteries.starter.voltage' },
|
||||
{ path: 'electrical.batteries.house.voltage' },
|
||||
{ path: 'steering.rudderAngle' },
|
||||
{ path: 'tanks.fuel.0.currentLevel' },
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
ws.onmessage = ({ data }) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
if (msg.updates) emit('delta', msg)
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
emit('disconnected', null)
|
||||
if (!destroyed) scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => { /* onclose will handle reconnect */ }
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||
reconnectAttempt++
|
||||
setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return {
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
disconnect() {
|
||||
destroyed = true
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
85
dashboard/src/api/snapcast.js
Normal file
85
dashboard/src/api/snapcast.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// Real Snapcast JSON-RPC WebSocket client with reconnect + request/response matching.
|
||||
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export function createSnapcastClient(wsUrl) {
|
||||
let ws = null
|
||||
let reconnectAttempt = 0
|
||||
let destroyed = false
|
||||
let msgId = 1
|
||||
const pending = new Map()
|
||||
const listeners = {}
|
||||
|
||||
function emit(event, data) {
|
||||
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
emit('connected', null)
|
||||
}
|
||||
|
||||
ws.onmessage = ({ data }) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
|
||||
// JSON-RPC response
|
||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(msg.error.message))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
|
||||
// Server-sent notification
|
||||
if (msg.method) emit('update', msg)
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
emit('disconnected', null)
|
||||
// Reject all pending requests
|
||||
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
|
||||
pending.clear()
|
||||
if (!destroyed) scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||
reconnectAttempt++
|
||||
setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return {
|
||||
call(method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return reject(new Error('Snapcast not connected'))
|
||||
}
|
||||
const id = msgId++
|
||||
pending.set(id, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }))
|
||||
})
|
||||
},
|
||||
on(event, fn) {
|
||||
if (!listeners[event]) listeners[event] = []
|
||||
listeners[event].push(fn)
|
||||
},
|
||||
off(event, fn) {
|
||||
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||
},
|
||||
disconnect() {
|
||||
destroyed = true
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user