Gitea Test
First Push to giTea
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import vaultwarden from './routes/vaultwarden'
|
|||||||
import kuma from './routes/kuma'
|
import kuma from './routes/kuma'
|
||||||
import arr from './routes/arr'
|
import arr from './routes/arr'
|
||||||
import qbt from './routes/qbittorrent'
|
import qbt from './routes/qbittorrent'
|
||||||
|
import jellyfin from './routes/jellyfin'
|
||||||
|
import navidrome from './routes/navidrome'
|
||||||
|
import romm from './routes/romm'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = Number(process.env.PORT ?? 3001)
|
const PORT = Number(process.env.PORT ?? 3001)
|
||||||
@@ -31,6 +34,9 @@ app.use('/api/vaultwarden', vaultwarden)
|
|||||||
app.use('/api/kuma', kuma)
|
app.use('/api/kuma', kuma)
|
||||||
app.use('/api/arr', arr)
|
app.use('/api/arr', arr)
|
||||||
app.use('/api/qbt', qbt)
|
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
|
// Serve built frontend in production only
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<string> | null = null
|
||||||
|
|
||||||
|
async function getToken(host: string, user: string, pass: string): Promise<string> {
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -14,6 +14,9 @@ import { QbittorrentWidget } from './components/widgets/QbittorrentWidget'
|
|||||||
import { GrafanaWidget } from './components/widgets/GrafanaWidget'
|
import { GrafanaWidget } from './components/widgets/GrafanaWidget'
|
||||||
import { PrometheusWidget } from './components/widgets/PrometheusWidget'
|
import { PrometheusWidget } from './components/widgets/PrometheusWidget'
|
||||||
import { LokiWidget } from './components/widgets/LokiWidget'
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +37,9 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="section-label">Media</div>
|
<div className="section-label">Media</div>
|
||||||
<div className="infra-grid">
|
<div className="infra-grid">
|
||||||
|
<JellyfinWidget />
|
||||||
|
<NavidromeWidget />
|
||||||
|
<RommWidget />
|
||||||
<ArrCalendarWidget />
|
<ArrCalendarWidget />
|
||||||
<ArrStatsWidget />
|
<ArrStatsWidget />
|
||||||
<QbittorrentWidget />
|
<QbittorrentWidget />
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useApi } from '../../hooks/useApi'
|
||||||
|
import { JellyfinData } from '../../types'
|
||||||
|
|
||||||
|
export function JellyfinWidget() {
|
||||||
|
const { data, loading, error } = useApi<JellyfinData>('/api/jellyfin/status', 30_000)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="widget-header">
|
||||||
|
<div className="widget-title">
|
||||||
|
<span className="dot" style={{ background: '#00a4dc' }} />
|
||||||
|
Jellyfin
|
||||||
|
</div>
|
||||||
|
{data && (
|
||||||
|
<div className="widget-badge">
|
||||||
|
{data.sessions.length > 0 ? `${data.sessions.length} playing` : 'idle'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="widget-loading">Connecting…</div>}
|
||||||
|
{error && <div className="widget-error">⚠ {error}</div>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="stat-row" style={{ marginBottom: 14 }}>
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="stat-value accent">{data.library.movies.toLocaleString()}</div>
|
||||||
|
<div className="stat-label">Movies</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="stat-value">{data.library.episodes.toLocaleString()}</div>
|
||||||
|
<div className="stat-label">Episodes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.sessions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--muted)' }}>No active streams</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 200, overflowY: 'auto' }}>
|
||||||
|
{data.sessions.map((s, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 10px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>{s.title}</span>
|
||||||
|
<span style={{ fontSize: 10, color: s.paused ? 'var(--yellow)' : 'var(--green)' }}>
|
||||||
|
{s.paused ? '⏸ paused' : '▶ playing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.subtitle && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text2)' }}>{s.subtitle}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{s.user} · {s.client}</span>
|
||||||
|
{s.progress !== null && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{s.progress}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.progress !== null && (
|
||||||
|
<div className="progress-track" style={{ marginTop: 2 }}>
|
||||||
|
<div className="progress-fill green" style={{ width: `${s.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useApi } from '../../hooks/useApi'
|
||||||
|
import { NavidromeData } from '../../types'
|
||||||
|
|
||||||
|
export function NavidromeWidget() {
|
||||||
|
const { data, loading, error } = useApi<NavidromeData>('/api/navidrome/status', 300_000)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="widget-header">
|
||||||
|
<div className="widget-title">
|
||||||
|
<span className="dot" style={{ background: '#ff6600' }} />
|
||||||
|
Navidrome
|
||||||
|
</div>
|
||||||
|
{data && (
|
||||||
|
<div className="widget-badge">
|
||||||
|
{data.nowPlaying.length > 0 ? `${data.nowPlaying.length} listening` : 'idle'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="widget-loading">Connecting…</div>}
|
||||||
|
{error && <div className="widget-error">⚠ {error}</div>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="stat-row" style={{ marginBottom: 14 }}>
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="stat-value accent">{data.artistCount.toLocaleString()}</div>
|
||||||
|
<div className="stat-label">Artists</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="stat-value">{data.albumCount.toLocaleString()}</div>
|
||||||
|
<div className="stat-label">Albums</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="stat-value">{data.songCount.toLocaleString()}</div>
|
||||||
|
<div className="stat-label">Songs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.nowPlaying.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--muted)' }}>Nothing playing</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 200, overflowY: 'auto' }}>
|
||||||
|
{data.nowPlaying.map((n, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 10px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 3,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>{n.title}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{n.artist} · {n.album}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text2)' }}>▶ {n.user}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useApi } from '../../hooks/useApi'
|
||||||
|
import { RommData } from '../../types'
|
||||||
|
|
||||||
|
export function RommWidget() {
|
||||||
|
const { data, loading, error } = useApi<RommData>('/api/romm/status', 120_000)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="widget-header">
|
||||||
|
<div className="widget-title">
|
||||||
|
<span className="dot" style={{ background: '#a855f7' }} />
|
||||||
|
RomM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="widget-loading">Connecting…</div>}
|
||||||
|
{error && <div className="widget-error">⚠ {error}</div>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="stat-row" style={{ marginBottom: 14 }}>
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="stat-value accent">{data.romCount.toLocaleString()}</div>
|
||||||
|
<div className="stat-label">ROMs</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<div className="stat-value">{data.platformCount}</div>
|
||||||
|
<div className="stat-label">Platforms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.platforms.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 160, overflowY: 'auto' }}>
|
||||||
|
{data.platforms.map((p, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 0',
|
||||||
|
borderBottom: i < data.platforms.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)' }}>{p.name}</span>
|
||||||
|
{p.romCount !== null && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||||
|
{p.romCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -142,3 +142,40 @@ export interface HeadscaleData {
|
|||||||
online: number
|
online: number
|
||||||
nodes: HeadscaleNode[]
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user