Feat livestream support (#180)

* experimental livestrea support

* test livestream

* update wait time detection

* update livestream functions

* persist and restore livestreams monitor session

* fan-in logging

* deps update

* added live time display

* livestream monitor prototype

* changed to default logger instead of passing *slog.Logger everywhere

* code refactoring, comments
This commit is contained in:
Marco Piovanello
2024-08-19 22:08:09 +02:00
committed by GitHub
parent a64798644a
commit fd5e62e23b
25 changed files with 865 additions and 95 deletions

View File

@@ -2,6 +2,7 @@ import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard'
import LiveTvIcon from '@mui/icons-material/LiveTv'
import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings'
import TerminalIcon from '@mui/icons-material/Terminal'
@@ -121,6 +122,19 @@ export default function Layout() {
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/monitor'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<LiveTvIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/log'} style={
{
textDecoration: 'none',

View File

@@ -54,6 +54,7 @@ languages:
rpcPollingTimeTitle: RPC polling time
rpcPollingTimeDescription: A lower interval results in higher CPU usage (server and client side)
templatesReloadInfo: To register a new template it might need a page reload.
livestreamURLInput: Livestream url
german:
urlInput: Video URL
statusTitle: Status

View File

@@ -0,0 +1,126 @@
import CloseIcon from '@mui/icons-material/Close'
import {
Alert,
AppBar,
Box,
Button,
Container,
Dialog,
Grid,
IconButton,
Paper,
Slide,
TextField,
Toolbar,
Typography
} from '@mui/material'
import { TransitionProps } from '@mui/material/transitions'
import { forwardRef, useState } from 'react'
import { useToast } from '../../hooks/toast'
import { useI18n } from '../../hooks/useI18n'
import { useRPC } from '../../hooks/useRPC'
type Props = {
open: boolean
onClose: () => void
}
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
const LivestreamDialog: React.FC<Props> = ({ open, onClose }) => {
const [livestreamURL, setLivestreamURL] = useState('')
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const exec = (url: string) => client.execLivestream(url)
return (
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={onClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Livestream monitor
</Typography>
</Toolbar>
</AppBar>
<Box sx={{
backgroundColor: (theme) => theme.palette.background.default,
minHeight: (theme) => `calc(99vh - ${theme.mixins.toolbar.minHeight}px)`
}}>
<Container sx={{ my: 4 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
elevation={4}
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12} mb={2}>
<Alert severity="info">
This will monitor yet to start livestream. Each process will be executed with --wait-for-video 10.<br />
If an already started livestream is provided it will be still downloaded but progress will not be tracked.
</Alert>
<Alert severity="warning" sx={{ mt: 1 }}>
This feature is still experimental. Something might break!
</Alert>
</Grid>
<Grid item xs={12}>
<TextField
multiline
fullWidth
label={i18n.t('livestreamURLInput')}
variant="outlined"
onChange={(e) => setLivestreamURL(e.target.value)}
/>
</Grid>
<Grid item>
<Button
sx={{ mt: 2 }}
variant="contained"
disabled={livestreamURL === ''}
onClick={() => {
exec(livestreamURL)
onClose()
pushMessage(`Monitoring ${livestreamURL}`, 'info')
}}
>
{i18n.t('startButton')}
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Container>
</Box>
</Dialog>
)
}
export default LivestreamDialog

View File

@@ -0,0 +1,34 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@mui/material'
import { useI18n } from '../../hooks/useI18n'
type Props = {
onOpen: () => void
onStopAll: () => void
}
const LivestreamSpeedDial: React.FC<Props> = ({ onOpen, onStopAll }) => {
const { i18n } = useI18n()
return (
<SpeedDial
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 64, right: 24 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={onStopAll}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={i18n.t('newDownloadButton')}
onClick={onOpen}
/>
</SpeedDial>
)
}
export default LivestreamSpeedDial

View File

@@ -0,0 +1,38 @@
import LiveTvIcon from '@mui/icons-material/LiveTv'
import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { useI18n } from '../../hooks/useI18n'
const FlexContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '80vh',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
})
const Title = styled(Typography)({
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: '0.5rem'
})
export default function NoLivestreams() {
const { i18n } = useI18n()
return (
<FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
<SvgIcon sx={{ fontSize: '200px' }}>
<LiveTvIcon />
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
No livestreams monitored
</Title>
</FlexContainer>
)
}

View File

@@ -1,5 +1,5 @@
import { Observable } from 'rxjs'
import type { DLMetadata, RPCRequest, RPCResponse, RPCResult } from '../types'
import type { DLMetadata, LiveStreamProgress, RPCRequest, RPCResponse, RPCResult } from '../types'
import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
@@ -160,9 +160,32 @@ export class RPCClient {
})
}
public updateExecutable() {
public execLivestream(url: string) {
return this.sendHTTP({
method: 'Service.UpdateExecutable',
method: 'Service.ExecLivestream',
params: [{
URL: url
}]
})
}
public progressLivestream() {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.ProgressLivestream',
params: []
})
}
public killLivestream(url: string) {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.KillLivestream',
params: [url]
})
}
public killAllLivestream() {
return this.sendHTTP<LiveStreamProgress>({
method: 'Service.KillAllLivestream',
params: []
})
}

View File

@@ -8,6 +8,7 @@ const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login'))
const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings'))
const LiveStream = lazy(() => import('./views/Livestream'))
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
@@ -74,6 +75,14 @@ export const router = createHashRouter([
</Suspense >
)
},
{
path: '/monitor',
element: (
<Suspense fallback={<CircularProgress />}>
<LiveStream />
</Suspense >
)
},
]
},
])

View File

@@ -9,6 +9,10 @@ export type RPCMethods =
| "Service.ExecPlaylist"
| "Service.DirectoryTree"
| "Service.UpdateExecutable"
| "Service.ExecLivestream"
| "Service.ProgressLivestream"
| "Service.KillLivestream"
| "Service.KillAllLivestream"
export type RPCRequest = {
method: RPCMethods
@@ -96,4 +100,10 @@ export type CustomTemplate = {
id: string
name: string
content: string
}
}
export type LiveStreamProgress = Record<string, {
Status: number
WaitTime: string
LiveDate: string
}>

View File

@@ -0,0 +1,126 @@
import {
Box,
Button,
Chip,
Container,
Paper,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
} from '@mui/material'
import { useState } from 'react'
import { interval } from 'rxjs'
import LivestreamDialog from '../components/livestream/LivestreamDialog'
import LivestreamSpeedDial from '../components/livestream/LivestreamSpeedDial'
import NoLivestreams from '../components/livestream/NoLivestreams'
import { useSubscription } from '../hooks/observable'
import { useRPC } from '../hooks/useRPC'
import { LiveStreamProgress } from '../types'
const LiveStreamMonitorView: React.FC = () => {
const { client } = useRPC()
const [progress, setProgress] = useState<LiveStreamProgress>()
const [openDialog, setOpenDialog] = useState(false)
useSubscription(interval(1000), () => {
client
.progressLivestream()
.then(r => setProgress(r.result))
})
const formatMicro = (microseconds: number) => {
const ms = microseconds / 1_000_000
let s = ms / 1000
const hr = s / 3600
s %= 3600
const mt = s / 60
s %= 60
// huh?
const ss = (Math.abs(s - 1)).toFixed(0).padStart(2, '0')
const mts = mt.toFixed(0).padStart(2, '0')
const hrs = hr.toFixed(0).padStart(2, '0')
return `${hrs}:${mts}:${ss}`
}
const mapStatusToChip = (status: number): React.ReactNode => {
switch (status) {
case 0:
return <Chip label='Waiting/Wait start' color='warning' size='small' />
case 1:
return <Chip label='Downloading' color='primary' size='small' />
case 2:
return <Chip label='Completed' color='success' size='small' />
case 3:
return <Chip label='Errored' color='error' size='small' />
default:
return <Chip label='Unknown state' color='primary' size='small' />
}
}
const stopAll = () => client.killAllLivestream()
const stop = (url: string) => client.killLivestream(url)
return (
<>
<LivestreamSpeedDial onOpen={() => setOpenDialog(s => !s)} onStopAll={stopAll} />
{progress && Object.keys(progress).length === 0 ?
<NoLivestreams /> :
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Paper sx={{
p: 2.5,
display: 'flex',
flexDirection: 'column',
minHeight: '80vh',
}}>
<TableContainer component={Box}>
<Table sx={{ minWidth: '100%' }}>
<TableHead>
<TableRow>
<TableCell>Livestream URL</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right">Time to live</TableCell>
<TableCell align="right">Starts on</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{progress && Object.keys(progress).map(k => (
<TableRow
key={k}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell>{k}</TableCell>
<TableCell align='right'>
{mapStatusToChip(progress[k].Status)}
</TableCell>
<TableCell align='right'>
{formatMicro(Number(progress[k].WaitTime))}
</TableCell>
<TableCell align='right'>
{new Date(progress[k].LiveDate).toLocaleString()}
</TableCell>
<TableCell align='right'>
<Button variant='contained' size='small' onClick={() => stop(k)}>
Stop
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Container>
}
<LivestreamDialog
open={openDialog}
onClose={() => setOpenDialog(s => !s)}
/>
</>
)
}
export default LiveStreamMonitorView