Move project from bordanlage/ to repo root

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:31:08 +01:00
parent 946c0a5377
commit 77123a0df5
56 changed files with 0 additions and 0 deletions

View 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}`
}
}
}

View 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()
}
}
}

View 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()
}
}
}

View 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()
}
}
}