Implement Tuner v1 — Go backend, React frontend, Docker setup

- Go backend: REST API, M3U parser, FFmpeg/MediaMTX process manager
- React/Vite frontend: HLS player, admin panel, channel browser (dark theme)
- MediaMTX config for RTMP ingest + HLS output
- Multi-stage Dockerfile (Go + Bun + Alpine runtime)
- docker-compose.yml for single-container deployment
- Sample M3U playlist with test streams

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 09:47:32 -08:00
parent 2e66b8c73d
commit b8bfcefee8
33 changed files with 2257 additions and 0 deletions

293
frontend/src/App.css Normal file
View File

@@ -0,0 +1,293 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.app {
max-width: 960px;
margin: 0 auto;
padding: 16px;
}
/* Player */
.player-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #111;
border-radius: 8px;
overflow: hidden;
}
.player-video {
width: 100%;
height: 100%;
display: block;
}
.player-video:not([src]),
.player-video[src=""] {
display: none;
}
.player-video:not([src]) ~ .player-offline,
.player-video[src=""] ~ .player-offline {
display: flex;
}
.player-offline {
display: none;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: #666;
letter-spacing: 0.05em;
}
/* StatusBar */
.status-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin-top: 8px;
background: #1a1a1a;
border-radius: 6px;
font-size: 0.875rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.live {
background: #22c55e;
box-shadow: 0 0 6px #22c55e80;
}
.status-dot.offline {
background: #555;
}
.status-text {
font-weight: 600;
}
.status-separator {
color: #444;
}
.status-source {
color: #aaa;
}
.status-channel {
color: #67e8f9;
}
/* Controls layout */
.controls {
display: grid;
grid-template-columns: 240px 1fr;
gap: 12px;
margin-top: 12px;
}
/* AdminPanel */
.admin-panel {
background: #1a1a1a;
border-radius: 6px;
padding: 16px;
}
.admin-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #888;
margin-bottom: 12px;
}
.source-buttons {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.source-btn {
flex: 1;
padding: 8px 12px;
border: 1px solid #333;
border-radius: 4px;
background: #2a2a2a;
color: #ccc;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
}
.source-btn:hover {
background: #333;
}
.source-btn.active {
background: #164e63;
border-color: #22d3ee;
color: #67e8f9;
}
.reload-btn {
width: 100%;
padding: 8px 12px;
border: 1px solid #333;
border-radius: 4px;
background: #2a2a2a;
color: #ccc;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.reload-btn:hover {
background: #333;
}
/* ChannelList */
.channel-list {
background: #1a1a1a;
border-radius: 6px;
padding: 16px;
display: flex;
flex-direction: column;
max-height: 500px;
}
.channel-list-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #888;
margin-bottom: 12px;
}
.channel-filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.channel-search {
flex: 1;
padding: 8px 10px;
border: 1px solid #333;
border-radius: 4px;
background: #2a2a2a;
color: #e0e0e0;
font-size: 0.875rem;
outline: none;
}
.channel-search:focus {
border-color: #555;
}
.channel-search::placeholder {
color: #666;
}
.channel-group-filter {
padding: 8px 10px;
border: 1px solid #333;
border-radius: 4px;
background: #2a2a2a;
color: #e0e0e0;
font-size: 0.875rem;
outline: none;
min-width: 140px;
}
.channel-items {
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.channel-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: #2a2a2a;
color: #e0e0e0;
text-align: left;
cursor: pointer;
transition: all 0.15s;
font-size: 0.875rem;
width: 100%;
}
.channel-item:hover {
background: #333;
}
.channel-item.active {
border-color: #22d3ee;
background: #164e63;
}
.channel-logo {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: contain;
background: #1a1a1a;
flex-shrink: 0;
}
.channel-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.channel-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-group {
font-size: 0.75rem;
color: #888;
}
.channel-empty {
padding: 24px;
text-align: center;
color: #666;
}
@media (max-width: 640px) {
.controls {
grid-template-columns: 1fr;
}
}

69
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from "react";
import type { StreamStatus, Channel } from "./types";
import * as api from "./api/client";
import Player from "./components/Player";
import StatusBar from "./components/StatusBar";
import AdminPanel from "./components/AdminPanel";
import ChannelList from "./components/ChannelList";
import "./App.css";
export default function App() {
const [status, setStatus] = useState<StreamStatus | null>(null);
const [channels, setChannels] = useState<Channel[]>([]);
const fetchStatus = useCallback(async () => {
try {
const s = await api.getStatus();
setStatus(s);
} catch {
// backend unreachable
}
}, []);
const fetchChannels = useCallback(async () => {
try {
const ch = await api.getChannels();
setChannels(ch);
} catch {
// backend unreachable
}
}, []);
useEffect(() => {
fetchStatus();
fetchChannels();
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, [fetchStatus, fetchChannels]);
const handleSourceChanged = () => {
fetchStatus();
};
const handlePlaylistReloaded = () => {
fetchChannels();
};
const handleChannelRefresh = () => {
fetchStatus();
};
return (
<div className="app">
<Player />
<StatusBar status={status} />
<div className="controls">
<AdminPanel
status={status}
onSourceChanged={handleSourceChanged}
onPlaylistReloaded={handlePlaylistReloaded}
/>
<ChannelList
channels={channels}
activeChannel={status?.channelName ?? ""}
onRefresh={handleChannelRefresh}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { Channel, StreamStatus, SystemStatus } from "../types";
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
return res.json();
}
async function fetchVoid(url: string, init?: RequestInit): Promise<void> {
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
}
export function getStatus(): Promise<StreamStatus> {
return fetchJSON<StreamStatus>("/api/status");
}
export function getChannels(search?: string, group?: string): Promise<Channel[]> {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (group) params.set("group", group);
const qs = params.toString();
return fetchJSON<Channel[]>(`/api/admin/channels${qs ? `?${qs}` : ""}`);
}
export function getGroups(): Promise<string[]> {
return fetchJSON<string[]>("/api/admin/groups");
}
export function setSource(source: string): Promise<void> {
return fetchVoid("/api/admin/source", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source }),
});
}
export function setChannel(channelId: string): Promise<void> {
return fetchVoid("/api/admin/channel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel_id: channelId }),
});
}
export function reloadPlaylist(): Promise<void> {
return fetchVoid("/api/admin/playlist/reload", { method: "POST" });
}
export function getProcessStatus(): Promise<SystemStatus> {
return fetchJSON<SystemStatus>("/api/admin/process/status");
}

View File

@@ -0,0 +1,53 @@
import type { StreamStatus } from "../types";
import * as api from "../api/client";
interface Props {
status: StreamStatus | null;
onSourceChanged: () => void;
onPlaylistReloaded: () => void;
}
export default function AdminPanel({ status, onSourceChanged, onPlaylistReloaded }: Props) {
const currentSource = status?.source ?? "obs";
const handleSource = async (source: "obs" | "iptv") => {
try {
await api.setSource(source);
onSourceChanged();
} catch (err) {
console.error("Failed to set source:", err);
}
};
const handleReload = async () => {
try {
await api.reloadPlaylist();
onPlaylistReloaded();
} catch (err) {
console.error("Failed to reload playlist:", err);
}
};
return (
<div className="admin-panel">
<h2 className="admin-title">Source</h2>
<div className="source-buttons">
<button
className={`source-btn ${currentSource === "obs" ? "active" : ""}`}
onClick={() => handleSource("obs")}
>
OBS
</button>
<button
className={`source-btn ${currentSource === "iptv" ? "active" : ""}`}
onClick={() => handleSource("iptv")}
>
IPTV
</button>
</div>
<button className="reload-btn" onClick={handleReload}>
Reload Playlist
</button>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useState, useEffect } from "react";
import type { Channel } from "../types";
import * as api from "../api/client";
interface Props {
channels: Channel[];
activeChannel: string;
onRefresh: () => void;
}
export default function ChannelList({ channels, activeChannel, onRefresh }: Props) {
const [search, setSearch] = useState("");
const [group, setGroup] = useState("");
const [groups, setGroups] = useState<string[]>([]);
const [filtered, setFiltered] = useState<Channel[]>(channels);
useEffect(() => {
api.getGroups().then(setGroups).catch(() => {});
}, []);
useEffect(() => {
const fetchFiltered = async () => {
try {
const result = await api.getChannels(search || undefined, group || undefined);
setFiltered(result);
} catch {
setFiltered(channels);
}
};
const timer = setTimeout(fetchFiltered, 300);
return () => clearTimeout(timer);
}, [search, group, channels]);
const handleSelect = async (ch: Channel) => {
try {
await api.setChannel(ch.id);
onRefresh();
} catch (err) {
console.error("Failed to set channel:", err);
}
};
return (
<div className="channel-list">
<h2 className="channel-list-title">Channels</h2>
<div className="channel-filters">
<input
className="channel-search"
type="text"
placeholder="Search channels..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
className="channel-group-filter"
value={group}
onChange={(e) => setGroup(e.target.value)}
>
<option value="">All Groups</option>
{groups.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</div>
<div className="channel-items">
{filtered.map((ch) => (
<button
key={ch.id}
className={`channel-item ${ch.name === activeChannel ? "active" : ""}`}
onClick={() => handleSelect(ch)}
>
{ch.logoUrl && (
<img className="channel-logo" src={ch.logoUrl} alt="" />
)}
<div className="channel-info">
<span className="channel-name">{ch.name}</span>
<span className="channel-group">{ch.group}</span>
</div>
</button>
))}
{filtered.length === 0 && (
<div className="channel-empty">No channels found</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef } from "react";
import Hls from "hls.js";
export default function Player() {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const src = `http://${window.location.hostname}:8888/live/stream/`;
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
});
hlsRef.current = hls;
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
setTimeout(() => hls.loadSource(src), 5000);
} else {
hls.destroy();
}
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src;
video.addEventListener("loadedmetadata", () => {
video.play().catch(() => {});
});
}
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, []);
return (
<div className="player-container">
<video ref={videoRef} className="player-video" controls playsInline />
<div className="player-offline">Stream Offline</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import type { StreamStatus } from "../types";
interface Props {
status: StreamStatus | null;
}
export default function StatusBar({ status }: Props) {
if (!status) {
return (
<div className="status-bar">
<span className="status-dot offline" />
<span className="status-text">Connecting...</span>
</div>
);
}
return (
<div className="status-bar">
<span className={`status-dot ${status.live ? "live" : "offline"}`} />
<span className="status-text">
{status.live ? "LIVE" : "Offline"}
</span>
<span className="status-separator">|</span>
<span className="status-source">
Source: {status.source === "obs" ? "OBS" : "IPTV"}
</span>
{status.source === "iptv" && status.channelName && (
<>
<span className="status-separator">|</span>
<span className="status-channel">{status.channelName}</span>
</>
)}
</div>
);
}

12
frontend/src/index.css Normal file
View File

@@ -0,0 +1,12 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: #e0e0e0;
background-color: #0a0a0a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,25 @@
export interface Channel {
id: string;
name: string;
group: string;
logoUrl: string;
streamUrl: string;
}
export interface StreamStatus {
source: "obs" | "iptv";
channelName: string;
live: boolean;
}
export interface ProcessInfo {
running: boolean;
pid: number;
uptime: string;
error: string;
}
export interface SystemStatus {
mediamtx: ProcessInfo;
ffmpeg: ProcessInfo;
}