diff --git a/.gitignore b/.gitignore index 37d7e73..a647117 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .env +.claude/ diff --git a/server/index.ts b/server/index.ts index 5e75a76..a248803 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,6 +13,9 @@ import vaultwarden from './routes/vaultwarden' import kuma from './routes/kuma' import arr from './routes/arr' import qbt from './routes/qbittorrent' +import jellyfin from './routes/jellyfin' +import navidrome from './routes/navidrome' +import romm from './routes/romm' const app = express() const PORT = Number(process.env.PORT ?? 3001) @@ -31,6 +34,9 @@ app.use('/api/vaultwarden', vaultwarden) app.use('/api/kuma', kuma) app.use('/api/arr', arr) app.use('/api/qbt', qbt) +app.use('/api/jellyfin', jellyfin) +app.use('/api/navidrome', navidrome) +app.use('/api/romm', romm) // Serve built frontend in production only if (process.env.NODE_ENV === 'production') { diff --git a/server/routes/jellyfin.ts b/server/routes/jellyfin.ts new file mode 100644 index 0000000..9e7bef1 --- /dev/null +++ b/server/routes/jellyfin.ts @@ -0,0 +1,58 @@ +import { Router } from 'express' +import axios from 'axios' + +const router = Router() + +router.get('/status', async (_req, res) => { + const host = process.env.JELLYFIN_HOST + const key = process.env.JELLYFIN_API_KEY + if (!host || !key) return res.status(503).json({ error: 'Jellyfin not configured' }) + + const headers = { 'X-Emby-Token': key } + + try { + const apiKey = { api_key: key } + const [sessionsRes, countsRes] = await Promise.all([ + axios.get(`${host}/Sessions`, { headers, params: { ...apiKey, ActiveWithinSeconds: 180 } }), + axios.get(`${host}/Items/Counts`, { headers, params: apiKey }), + ]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sessions = (sessionsRes.data as any[]) + .filter((s: any) => s.NowPlayingItem) + .map((s: any) => { + const item = s.NowPlayingItem + const isEpisode = item?.Type === 'Episode' + const title = isEpisode ? (item?.SeriesName ?? item?.Name ?? '') : (item?.Name ?? '') + const subtitle = isEpisode + ? `S${String(item?.ParentIndexNumber ?? 0).padStart(2,'0')}E${String(item?.IndexNumber ?? 0).padStart(2,'0')} · ${item?.Name ?? ''}` + : null + return { + user: s.UserName ?? 'Unknown', + title, + subtitle, + type: item?.Type ?? '', + progress: s.PlayState?.PositionTicks && item?.RunTimeTicks + ? Math.round((s.PlayState.PositionTicks / item.RunTimeTicks) * 100) + : null, + paused: s.PlayState?.IsPaused ?? false, + client: s.Client ?? '', + } + }) + + const counts = countsRes.data as any + res.json({ + sessions, + library: { + movies: counts.MovieCount ?? 0, + episodes: counts.EpisodeCount ?? 0, + songs: counts.SongCount ?? 0, + albums: counts.AlbumCount ?? 0, + }, + }) + } catch (err: any) { + res.status(502).json({ error: err.message ?? 'Jellyfin error' }) + } +}) + +export default router diff --git a/server/routes/navidrome.ts b/server/routes/navidrome.ts new file mode 100644 index 0000000..0c26849 --- /dev/null +++ b/server/routes/navidrome.ts @@ -0,0 +1,72 @@ +import { Router } from 'express' +import axios from 'axios' +import crypto from 'crypto' + +const router = Router() + +let cachedToken: string | null = null +let tokenExpiry = 0 +let tokenPromise: Promise | null = null + +async function getToken(host: string, user: string, pass: string): Promise { + if (cachedToken && Date.now() < tokenExpiry) return cachedToken + // Deduplicate concurrent login attempts + if (tokenPromise) return tokenPromise + tokenPromise = axios.post(`${host}/auth/login`, { username: user, password: pass }) + .then(res => { + cachedToken = res.data.token + tokenExpiry = Date.now() + 55 * 60 * 1000 + tokenPromise = null + return cachedToken! + }) + .catch(err => { + tokenPromise = null + throw err + }) + return tokenPromise +} + +router.get('/status', async (_req, res) => { + const host = process.env.NAVIDROME_HOST + const user = process.env.NAVIDROME_USER + const pass = process.env.NAVIDROME_PASSWORD + if (!host || !user || !pass) + return res.status(503).json({ error: 'Navidrome not configured' }) + + try { + const jwtToken = await getToken(host, user, pass) + const headers = { 'X-ND-Authorization': `Bearer ${jwtToken}` } + + // Use Subsonic API for now playing (standard endpoint) + const salt = 'sycoDash' + const md5Token = crypto.createHash('md5').update(pass + salt).digest('hex') + const subParams = { u: user, t: md5Token, s: salt, v: '1.16.1', c: 'syco-dashboard', f: 'json' } + + const [artistRes, albumRes, songRes, nowPlayingRes] = await Promise.all([ + axios.get(`${host}/api/artist`, { headers, params: { _start: 0, _end: 1 } }), + axios.get(`${host}/api/album`, { headers, params: { _start: 0, _end: 1 } }), + axios.get(`${host}/api/song`, { headers, params: { _start: 0, _end: 1 } }), + axios.get(`${host}/rest/getNowPlaying.view`, { params: subParams }).catch(() => null), + ]) + + const artistCount = Number(artistRes.headers['x-total-count'] ?? 0) + const albumCount = Number(albumRes.headers['x-total-count'] ?? 0) + const songCount = Number(songRes.headers['x-total-count'] ?? 0) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nowPlayingEntries: any[] = nowPlayingRes?.data?.['subsonic-response']?.nowPlaying?.entry ?? [] + const entries = Array.isArray(nowPlayingEntries) ? nowPlayingEntries : [nowPlayingEntries] + const nowPlaying = entries.filter(Boolean).map((e: any) => ({ + user: e.username ?? 'Unknown', + title: e.title ?? '', + artist: e.artist ?? '', + album: e.album ?? '', + })) + + res.json({ artistCount, albumCount, songCount, nowPlaying }) + } catch (err: any) { + res.status(502).json({ error: err.message ?? 'Navidrome error' }) + } +}) + +export default router diff --git a/server/routes/romm.ts b/server/routes/romm.ts new file mode 100644 index 0000000..5f67b0e --- /dev/null +++ b/server/routes/romm.ts @@ -0,0 +1,38 @@ +import { Router } from 'express' +import axios from 'axios' + +const router = Router() + +router.get('/status', async (_req, res) => { + const host = process.env.ROMM_HOST + const user = process.env.ROMM_USER + const pass = process.env.ROMM_PASSWORD + if (!host || !user || !pass) return res.status(503).json({ error: 'RomM not configured' }) + + const auth = { username: user, password: pass } + + try { + const [statsRes, platformsRes] = await Promise.all([ + axios.get(`${host}/api/stats`, { auth }), + axios.get(`${host}/api/platforms`, { auth }), + ]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stats: any = statsRes.data ?? {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const platforms: any[] = platformsRes.data ?? [] + + res.json({ + platformCount: stats.PLATFORMS ?? platforms.length, + romCount: stats.ROMS ?? 0, + platforms: platforms.map((p: any) => ({ + name: p.name ?? p.fs_slug ?? p.slug ?? '', + romCount: p.rom_count ?? p.roms_count ?? null, + })), + }) + } catch (err: any) { + res.status(502).json({ error: err.message ?? 'RomM error' }) + } +}) + +export default router diff --git a/src/App.tsx b/src/App.tsx index 963b696..ed4a29d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,9 @@ import { QbittorrentWidget } from './components/widgets/QbittorrentWidget' import { GrafanaWidget } from './components/widgets/GrafanaWidget' import { PrometheusWidget } from './components/widgets/PrometheusWidget' import { LokiWidget } from './components/widgets/LokiWidget' +import { JellyfinWidget } from './components/widgets/JellyfinWidget' +import { NavidromeWidget } from './components/widgets/NavidromeWidget' +import { RommWidget } from './components/widgets/RommWidget' export default function App() { return ( @@ -34,6 +37,9 @@ export default function App() {
Media
+ + + diff --git a/src/components/widgets/JellyfinWidget.tsx b/src/components/widgets/JellyfinWidget.tsx new file mode 100644 index 0000000..1d950ce --- /dev/null +++ b/src/components/widgets/JellyfinWidget.tsx @@ -0,0 +1,78 @@ +import { useApi } from '../../hooks/useApi' +import { JellyfinData } from '../../types' + +export function JellyfinWidget() { + const { data, loading, error } = useApi('/api/jellyfin/status', 30_000) + + return ( +
+
+
+ + Jellyfin +
+ {data && ( +
+ {data.sessions.length > 0 ? `${data.sessions.length} playing` : 'idle'} +
+ )} +
+ + {loading &&
Connecting…
} + {error &&
⚠ {error}
} + + {data && ( + <> +
+
+
{data.library.movies.toLocaleString()}
+
Movies
+
+
+
{data.library.episodes.toLocaleString()}
+
Episodes
+
+
+ + {data.sessions.length === 0 ? ( +
No active streams
+ ) : ( +
+ {data.sessions.map((s, i) => ( +
+
+ {s.title} + + {s.paused ? '⏸ paused' : '▶ playing'} + +
+ {s.subtitle && ( +
{s.subtitle}
+ )} +
+ {s.user} · {s.client} + {s.progress !== null && ( + {s.progress}% + )} +
+ {s.progress !== null && ( +
+
+
+ )} +
+ ))} +
+ )} + + )} +
+ ) +} diff --git a/src/components/widgets/NavidromeWidget.tsx b/src/components/widgets/NavidromeWidget.tsx new file mode 100644 index 0000000..3f5a9f9 --- /dev/null +++ b/src/components/widgets/NavidromeWidget.tsx @@ -0,0 +1,65 @@ +import { useApi } from '../../hooks/useApi' +import { NavidromeData } from '../../types' + +export function NavidromeWidget() { + const { data, loading, error } = useApi('/api/navidrome/status', 300_000) + + return ( +
+
+
+ + Navidrome +
+ {data && ( +
+ {data.nowPlaying.length > 0 ? `${data.nowPlaying.length} listening` : 'idle'} +
+ )} +
+ + {loading &&
Connecting…
} + {error &&
⚠ {error}
} + + {data && ( + <> +
+
+
{data.artistCount.toLocaleString()}
+
Artists
+
+
+
{data.albumCount.toLocaleString()}
+
Albums
+
+
+
{data.songCount.toLocaleString()}
+
Songs
+
+
+ + {data.nowPlaying.length === 0 ? ( +
Nothing playing
+ ) : ( +
+ {data.nowPlaying.map((n, i) => ( +
+
{n.title}
+
{n.artist} · {n.album}
+
▶ {n.user}
+
+ ))} +
+ )} + + )} +
+ ) +} diff --git a/src/components/widgets/RommWidget.tsx b/src/components/widgets/RommWidget.tsx new file mode 100644 index 0000000..919bdb4 --- /dev/null +++ b/src/components/widgets/RommWidget.tsx @@ -0,0 +1,56 @@ +import { useApi } from '../../hooks/useApi' +import { RommData } from '../../types' + +export function RommWidget() { + const { data, loading, error } = useApi('/api/romm/status', 120_000) + + return ( +
+
+
+ + RomM +
+
+ + {loading &&
Connecting…
} + {error &&
⚠ {error}
} + + {data && ( + <> +
+
+
{data.romCount.toLocaleString()}
+
ROMs
+
+
+
{data.platformCount}
+
Platforms
+
+
+ + {data.platforms.length > 0 && ( +
+ {data.platforms.map((p, i) => ( +
+ {p.name} + {p.romCount !== null && ( + + {p.romCount} + + )} +
+ ))} +
+ )} + + )} +
+ ) +} diff --git a/src/types.ts b/src/types.ts index db92489..7bef317 100644 --- a/src/types.ts +++ b/src/types.ts @@ -142,3 +142,40 @@ export interface HeadscaleData { online: number nodes: HeadscaleNode[] } + +export interface JellyfinSession { + user: string + title: string + subtitle: string | null + type: string + progress: number | null + paused: boolean + client: string +} +export interface JellyfinData { + sessions: JellyfinSession[] + library: { movies: number; episodes: number; songs: number; albums: number } +} + +export interface NavidromeNowPlaying { + user: string + title: string + artist: string + album: string +} +export interface NavidromeData { + artistCount: number + albumCount: number + songCount: number + nowPlaying: NavidromeNowPlaying[] +} + +export interface RommPlatform { + name: string + romCount: number | null +} +export interface RommData { + platformCount: number + romCount: number + platforms: RommPlatform[] +}