Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9b0fde520 | ||
|
|
6e123c319f | ||
|
|
de975f758f | ||
| d3371ed64c | |||
| 15766bd016 | |||
| c78b3ae174 | |||
| 3d9a7e9810 | |||
|
|
8aeffb8d9f | ||
|
|
7904904a37 | ||
|
|
6aa2d41988 | ||
| de1d9e6a3c | |||
|
|
1b46f0dd03 | ||
|
|
8870602268 | ||
|
|
15d9d261a3 | ||
|
|
f3302c17cc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@ session.dat
|
||||
config.yml
|
||||
cookies.txt
|
||||
__debug*
|
||||
ui/
|
||||
26
README.md
26
README.md
@@ -1,3 +1,8 @@
|
||||
> [!IMPORTANT]
|
||||
> Major frontend refactoring in progress.
|
||||
> I won't add features or fix minor issues until completition.
|
||||
---
|
||||
|
||||
# yt-dlp Web UI
|
||||
|
||||
A not so terrible web ui for yt-dlp.
|
||||
@@ -17,9 +22,8 @@ docker pull marcobaobao/yt-dlp-webui
|
||||
```sh
|
||||
# latest dev
|
||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
||||
# latest stable version
|
||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
|
||||
```
|
||||

|
||||
|
||||

|
||||

|
||||
@@ -75,20 +79,10 @@ The currently avaible settings are:
|
||||
|
||||
## Format selection
|
||||
|
||||

|
||||
|
||||
This feature is disabled by default as this intended to be used to retrieve the best quality automatically.
|
||||
|
||||
To enable it just go to the settings page and enable the **Enable video/audio formats selection** flag!
|
||||
|
||||
Future releases will have:
|
||||
- ~~Multi download~~ *done*
|
||||
- ~~Exctract audio~~ *done*
|
||||
- ~~Format selection~~ *done*
|
||||
- ~~Download archive~~ *done*
|
||||
- ~~ARM Build~~ *done available through ghcr.io*
|
||||
- Playlist support
|
||||
|
||||
## Troubleshooting
|
||||
- **It says that it isn't connected/ip in the header is not defined.**
|
||||
- You must set the server ip address in the settings section (gear icon).
|
||||
@@ -97,7 +91,6 @@ Future releases will have:
|
||||
|
||||
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
|
||||
```sh
|
||||
# recomended for ARM and x86 devices
|
||||
docker pull marcobaobao/yt-dlp-webui
|
||||
docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
|
||||
```
|
||||
@@ -250,12 +243,5 @@ Just as an overview, these are the available methods:
|
||||
|
||||
For more information open an issue on GitHub and I will provide more info ASAP.
|
||||
|
||||
## FAQ
|
||||
- **Will it availabe for Raspberry Pi/ generic ARM devices?**
|
||||
- Yes, it's cross platform :)
|
||||
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
|
||||
- **Why the docker image is so heavy?**
|
||||
- Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp.
|
||||
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
|
||||
## What yt-dlp-webui is not
|
||||
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.
|
||||
|
||||
@@ -10,27 +10,29 @@
|
||||
"license": "MPL-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/roboto": "^5.0.6",
|
||||
"@mui/icons-material": "^5.11.16",
|
||||
"@mui/material": "^5.13.5",
|
||||
"fp-ts": "^2.16.1",
|
||||
"@fontsource/roboto": "^5.0.8",
|
||||
"@fontsource/roboto-mono": "^5.0.16",
|
||||
"@mui/icons-material": "^5.15.4",
|
||||
"@mui/material": "^5.15.4",
|
||||
"fp-ts": "^2.16.2",
|
||||
"million": "^2.6.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.17.0",
|
||||
"react-router-dom": "^6.21.2",
|
||||
"recoil": "^0.7.7",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||
"@types/node": "^20.8.7",
|
||||
"@types/react": "^18.2.29",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"@types/react-helmet": "^6.1.8",
|
||||
"@types/node": "^20.11.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react-swc": "^3.4.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^4.5.0"
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
1214
frontend/pnpm-lock.yaml
generated
1214
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ import SocketSubscriber from './components/SocketSubscriber'
|
||||
import ThemeToggler from './components/ThemeToggler'
|
||||
import { useI18n } from './hooks/useI18n'
|
||||
import Toaster from './providers/ToasterProvider'
|
||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||
|
||||
export default function Layout() {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -138,6 +139,19 @@ export default function Layout() {
|
||||
<ListItemText primary={i18n.t('archiveButtonLabel')} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/log'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
color: mode === 'dark' ? '#ffffff' : '#000000DE'
|
||||
}
|
||||
}>
|
||||
<ListItemButton>
|
||||
<ListItemIcon>
|
||||
<TerminalIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={i18n.t('logsTitle')} />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link to={'/settings'} style={
|
||||
{
|
||||
textDecoration: 'none',
|
||||
|
||||
@@ -47,6 +47,57 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
german:
|
||||
urlInput: Video URL
|
||||
statusTitle: Status
|
||||
statusReady: Bereit
|
||||
selectFormatButton: Format auswählen
|
||||
startButton: Start
|
||||
abortAllButton: Alle Abbrechen
|
||||
updateBinButton: yt-dlp Binärdatei aktualisieren
|
||||
darkThemeButton: Dunkel Modus
|
||||
lightThemeButton: Hell Modus
|
||||
settingsAnchor: Einstellungen
|
||||
serverAddressTitle: Server Adresse
|
||||
serverPortTitle: Port
|
||||
extractAudioCheckbox: Audio extrahieren
|
||||
noMTimeCheckbox: Datei-Änderungszeitpunkt nicht festlegen
|
||||
bgReminder: Sobald Sie diese Seite schließen, wird der Download im Hintergrund fortgesetzt.
|
||||
toastConnected: 'Verbunden mit '
|
||||
toastUpdated: yt-dlp Binärdatei aktualisiert!
|
||||
formatSelectionEnabler: Video/Audio Format auswählbar
|
||||
themeSelect: 'Modus'
|
||||
languageSelect: 'Sprache'
|
||||
overridesAnchor: Überschreibungen
|
||||
pathOverrideOption: Ausgabe-Pfad Überschreibung aktivieren
|
||||
filenameOverrideOption: Ausgabe-Dateiname Überschreibung aktivieren
|
||||
customFilename: Custom filemame (leave blank to use default)
|
||||
customPath: Benutzerdefinierter Pfad
|
||||
customArgs: Benutzerdefinierte yt-dlp Argumente aktivieren (viel Macht = viel Verantwortung)
|
||||
customArgsInput: Benutzerdefinierte yt-dlp Argumente
|
||||
rpcConnErr: Fehler beim Verbinden mit RPC Server
|
||||
splashText: Keine aktiven Downloads
|
||||
archiveTitle: Archiv
|
||||
clipboardAction: URL in Zwischenablage kopiert
|
||||
playlistCheckbox: Playlist herunterladen (es wird einige Zeit dauern, nach dem Absenden können Sie dieses Fenster schließen)
|
||||
restartAppMessage: Erfordert ein Neuladen der Seite, um wirksam zu werden
|
||||
servedFromReverseProxyCheckbox: Ist hinter einem Reverse Proxy Unterordner
|
||||
newDownloadButton: Neuer Download
|
||||
homeButtonLabel: Home
|
||||
archiveButtonLabel: Archiv
|
||||
settingsButtonLabel: Einstellungen
|
||||
rpcAuthenticationLabel: RPC Authentifizierung
|
||||
themeTogglerLabel: Modus Umschalter
|
||||
loadingLabel: Lädt...
|
||||
appTitle: App Titel
|
||||
savedTemplates: Gespeicherte Vorlage
|
||||
templatesEditor: Vorlagen Bearbeiter
|
||||
templatesEditorNameLabel: Vorlagen Name
|
||||
templatesEditorContentLabel: Vorlagen Inhalt
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
french:
|
||||
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
||||
statusTitle: Statut
|
||||
@@ -96,6 +147,8 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
italian:
|
||||
urlInput: URL Video
|
||||
statusTitle: Stato
|
||||
@@ -129,7 +182,7 @@ languages:
|
||||
clipboardAction: URL copiato negli appunti
|
||||
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
||||
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||
servedFromReverseProxyCheckbox: Is behind a reverse proxy
|
||||
newDownloadButton: Nuovo download
|
||||
homeButtonLabel: Home
|
||||
archiveButtonLabel: Archive
|
||||
@@ -142,8 +195,10 @@ languages:
|
||||
templatesEditor: Editor template
|
||||
templatesEditorNameLabel: Nome template
|
||||
templatesEditorContentLabel: Contentunto template
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
chinese:
|
||||
urlInput: YouTube 或其他受支持服务的视频网址
|
||||
urlInput: 视频 URL
|
||||
statusTitle: 状态
|
||||
statusReady: 就绪
|
||||
selectFormatButton: 选择格式
|
||||
@@ -177,18 +232,20 @@ languages:
|
||||
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
|
||||
restartAppMessage: 需要刷新页面才能生效
|
||||
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
|
||||
newDownloadButton: New download
|
||||
homeButtonLabel: Home
|
||||
archiveButtonLabel: Archive
|
||||
settingsButtonLabel: Settings
|
||||
rpcAuthenticationLabel: RPC authentication
|
||||
themeTogglerLabel: Theme toggler
|
||||
loadingLabel: Loading...
|
||||
newDownloadButton: 新下载
|
||||
homeButtonLabel: 主页
|
||||
archiveButtonLabel: 归档
|
||||
settingsButtonLabel: 设置
|
||||
rpcAuthenticationLabel: RPC 身份验证
|
||||
themeTogglerLabel: 主题切换
|
||||
loadingLabel: 正在加载…
|
||||
appTitle: App 标题
|
||||
savedTemplates: Saved templates
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
savedTemplates: 保存模板
|
||||
templatesEditor: 模板编辑器
|
||||
templatesEditorNameLabel: 模板名称
|
||||
templatesEditorContentLabel: 模板内容
|
||||
logsTitle: '日志'
|
||||
awaitingLogs: '正在等待日志…'
|
||||
spanish:
|
||||
urlInput: URL de YouTube u otro servicio compatible
|
||||
statusTitle: Estado
|
||||
@@ -234,6 +291,8 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
russian:
|
||||
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
||||
statusTitle: Статус
|
||||
@@ -279,6 +338,8 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
korean:
|
||||
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
||||
statusTitle: 상태
|
||||
@@ -324,6 +385,8 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
japanese:
|
||||
urlInput: YouTubeまたはサポート済み動画のURL
|
||||
statusTitle: 状態
|
||||
@@ -370,6 +433,8 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
catalan:
|
||||
urlInput: URL de YouTube o d'un altre servei compatible
|
||||
statusTitle: Estat
|
||||
@@ -415,6 +480,8 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
ukrainian:
|
||||
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
||||
statusTitle: Статус
|
||||
@@ -460,6 +527,8 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
polish:
|
||||
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
||||
statusTitle: Status
|
||||
@@ -505,3 +574,5 @@ languages:
|
||||
templatesEditor: Templates editor
|
||||
templatesEditorNameLabel: Template name
|
||||
templatesEditorContentLabel: Template content
|
||||
logsTitle: 'Logs'
|
||||
awaitingLogs: 'Awaiting logs...'
|
||||
|
||||
@@ -12,6 +12,7 @@ export const languages = [
|
||||
'catalan',
|
||||
'ukrainian',
|
||||
'polish',
|
||||
'german'
|
||||
] as const
|
||||
|
||||
export type Language = (typeof languages)[number]
|
||||
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
useTransition
|
||||
} from 'react'
|
||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||
import { customArgsState, downloadTemplateState, filenameTemplateState } from '../atoms/downloadTemplate'
|
||||
import { settingsState } from '../atoms/settings'
|
||||
import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate'
|
||||
import { latestCliArgumentsState, settingsState } from '../atoms/settings'
|
||||
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
||||
import FormatsGrid from '../components/FormatsGrid'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
@@ -63,6 +63,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
const isConnected = useRecoilValue(connectedState)
|
||||
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState)
|
||||
const downloadTemplate = useRecoilValue(downloadTemplateState)
|
||||
const savedTemplates = useRecoilValue(savedTemplatesState)
|
||||
|
||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||
@@ -70,6 +71,8 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||
|
||||
const [customArgs, setCustomArgs] = useRecoilState(customArgsState)
|
||||
const [, setCliArgs] = useRecoilState(latestCliArgumentsState)
|
||||
|
||||
const [downloadPath, setDownloadPath] = useState('')
|
||||
|
||||
const [filenameTemplate, setFilenameTemplate] = useRecoilState(
|
||||
@@ -81,7 +84,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
|
||||
const [isPlaylist, setIsPlaylist] = useState(false)
|
||||
|
||||
const cliArgs = useMemo(() =>
|
||||
const argsBuilder = useMemo(() =>
|
||||
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
|
||||
)
|
||||
|
||||
@@ -108,7 +111,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
|
||||
client.download({
|
||||
url: immediate || url || workingUrl,
|
||||
args: `${cliArgs.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||
args: `${argsBuilder.toString()} ${toFormatArgs(codes)} ${downloadTemplate}`,
|
||||
pathOverride: downloadPath ?? '',
|
||||
renameTo: settings.fileRenaming ? filenameTemplate : '',
|
||||
playlist: isPlaylist,
|
||||
@@ -313,9 +316,31 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
}
|
||||
</Grid>
|
||||
<Suspense>
|
||||
<ExtraDownloadOptions />
|
||||
{savedTemplates.length > 0 && <ExtraDownloadOptions />}
|
||||
</Suspense>
|
||||
<Grid container spacing={1} pt={2} justifyContent="space-between">
|
||||
<Grid item>
|
||||
<Grid item>
|
||||
<FormControlLabel
|
||||
control={<Checkbox onChange={() => setIsPlaylist(state => !state)} />}
|
||||
checked={isPlaylist}
|
||||
label={i18n.t('playlistCheckbox')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
|
||||
/>
|
||||
}
|
||||
checked={argsBuilder.extractAudio}
|
||||
onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
|
||||
disabled={settings.formatSelection}
|
||||
label={i18n.t('extractAudioCheckbox')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
@@ -332,13 +357,6 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
|
||||
}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<FormControlLabel
|
||||
control={<Checkbox onChange={() => setIsPlaylist(state => !state)} />}
|
||||
checked={isPlaylist}
|
||||
label={i18n.t('playlistCheckbox')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
@@ -27,9 +27,14 @@ const DownloadsListView: React.FC = () => {
|
||||
return (
|
||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||
<Grid item xs={12}>
|
||||
<TableContainer component={Paper} sx={{ minHeight: '100%' }} elevation={2}>
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{ minHeight: '100%' }}
|
||||
elevation={2}
|
||||
hidden={downloads.length === 0}
|
||||
>
|
||||
<Table>
|
||||
<TableHead hidden={downloads.length === 0}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography fontWeight={500} fontSize={15}>Title</Typography>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Autocomplete, Box, TextField } from '@mui/material'
|
||||
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
|
||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
@@ -22,9 +22,23 @@ const ExtraDownloadOptions: React.FC = () => {
|
||||
renderOption={(props, option) => (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{ mr: 2, flexShrink: 0 }}
|
||||
{...props}>
|
||||
{option.label}
|
||||
{...props}
|
||||
>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignContent: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Typography>
|
||||
{option.label}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{option.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ width: '100%', mt: 2 }}
|
||||
|
||||
91
frontend/src/components/LogTerminal.tsx
Normal file
91
frontend/src/components/LogTerminal.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Box, CircularProgress, Container, Paper, Typography } from '@mui/material'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRecoilValue } from 'recoil'
|
||||
import { serverURL } from '../atoms/settings'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const LogTerminal: React.FC = () => {
|
||||
const serverAddr = useRecoilValue(serverURL)
|
||||
|
||||
const { i18n } = useI18n()
|
||||
|
||||
const [logBuffer, setLogBuffer] = useState<string[]>([])
|
||||
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const eventSource = useMemo(
|
||||
() => new EventSource(`${serverAddr}/log/sse?token=${token}`),
|
||||
[serverAddr]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
eventSource.addEventListener('log', event => {
|
||||
const msg: string[] = JSON.parse(event.data)
|
||||
setLogBuffer(buff => [...buff, ...msg].slice(-100))
|
||||
|
||||
boxRef.current?.scrollTo(0, boxRef.current.scrollHeight)
|
||||
})
|
||||
|
||||
// TODO: in dev mode it breaks sse
|
||||
return () => eventSource.close()
|
||||
}, [eventSource])
|
||||
|
||||
const logEntryStyle = (data: string) => {
|
||||
if (data.includes("level=ERROR")) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
if (data.includes("level=WARN")) {
|
||||
return { color: 'orange' }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Typography py={1} variant="h5" color="primary">
|
||||
{i18n.t('logsTitle')}
|
||||
</Typography>
|
||||
{(logBuffer.length === 0) && <Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyItems: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}>
|
||||
<CircularProgress color="primary" size={32} />
|
||||
<Typography py={1} variant="subtitle2" >
|
||||
{i18n.t('awaitingLogs')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
<Box
|
||||
ref={boxRef}
|
||||
sx={{
|
||||
fontFamily: 'Roboto Mono',
|
||||
height: '75.5vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
fontSize: '15px'
|
||||
}}
|
||||
>
|
||||
{logBuffer.map((log, idx) => (
|
||||
<Box key={idx} sx={logEntryStyle(log)}>
|
||||
{log}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container >
|
||||
)
|
||||
}
|
||||
|
||||
export default LogTerminal
|
||||
@@ -6,6 +6,9 @@ import '@fontsource/roboto/300.css'
|
||||
import '@fontsource/roboto/400.css'
|
||||
import '@fontsource/roboto/500.css'
|
||||
import '@fontsource/roboto/700.css'
|
||||
import '@fontsource/roboto/700.css'
|
||||
|
||||
import '@fontsource/roboto-mono'
|
||||
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (opt?.headers) {
|
||||
opt.headers = { ...opt.headers, 'X-Authentication': jwt ?? '' }
|
||||
}
|
||||
|
||||
const res = await fetch(url, opt)
|
||||
const res = await fetch(url, {
|
||||
...opt,
|
||||
headers: {
|
||||
...opt?.headers,
|
||||
'X-Authentication': jwt ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw await res.text()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CircularProgress } from '@mui/material'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { createHashRouter } from 'react-router-dom'
|
||||
import Layout from './Layout'
|
||||
import Terminal from './views/Terminal'
|
||||
|
||||
const Home = lazy(() => import('./views/Home'))
|
||||
const Login = lazy(() => import('./views/Login'))
|
||||
@@ -36,6 +37,14 @@ export const router = createHashRouter([
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/log',
|
||||
element: (
|
||||
<Suspense fallback={<CircularProgress />}>
|
||||
<Terminal />
|
||||
</Suspense >
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/archive',
|
||||
element: (
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Paper,
|
||||
SpeedDial,
|
||||
SpeedDialAction,
|
||||
@@ -27,6 +28,7 @@ import FolderIcon from '@mui/icons-material/Folder'
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||
import { pipe } from 'fp-ts/lib/function'
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||
@@ -38,12 +40,14 @@ import { useObservable } from '../hooks/observable'
|
||||
import { useToast } from '../hooks/toast'
|
||||
import { useI18n } from '../hooks/useI18n'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
import { DeleteRequest, DirectoryEntry } from '../types'
|
||||
import { DirectoryEntry } from '../types'
|
||||
import { base64URLEncode, roundMiB } from '../utils'
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
|
||||
|
||||
export default function Downloaded() {
|
||||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
||||
|
||||
const serverAddr = useRecoilValue(serverURL)
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -122,7 +126,7 @@ export default function Downloaded() {
|
||||
combineLatestWith(selected$),
|
||||
map(([data, selected]) => data.map(x => ({
|
||||
...x,
|
||||
selected: selected.includes(x.path)
|
||||
selected: selected.includes(x.name)
|
||||
}))),
|
||||
share()
|
||||
), [])
|
||||
@@ -135,19 +139,24 @@ export default function Downloaded() {
|
||||
: selected$.next([...selected$.value, name])
|
||||
}
|
||||
|
||||
const deleteFile = (entry: DirectoryEntry) => pipe(
|
||||
ffetch(`${serverAddr}/archive/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
path: entry.path,
|
||||
shaSum: entry.shaSum,
|
||||
})
|
||||
}),
|
||||
matchW(
|
||||
(l) => pushMessage(l, 'error'),
|
||||
(_) => fetcher()
|
||||
)
|
||||
)()
|
||||
|
||||
const deleteSelected = () => {
|
||||
Promise.all(selectable
|
||||
.filter(entry => entry.selected)
|
||||
.map(entry => fetch(`${serverAddr}/archive/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: entry.path,
|
||||
shaSum: entry.shaSum,
|
||||
} as DeleteRequest)
|
||||
}))
|
||||
.map(deleteFile)
|
||||
).then(fetcher)
|
||||
}
|
||||
|
||||
@@ -172,18 +181,42 @@ export default function Downloaded() {
|
||||
})
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{ mt: 4, mb: 4, height: '100%' }}
|
||||
onClick={() => setShowMenu(false)}
|
||||
>
|
||||
<IconMenu
|
||||
posX={menuPos.x}
|
||||
posY={menuPos.y}
|
||||
hide={!showMenu}
|
||||
onDownload={() => {
|
||||
if (currentFile) {
|
||||
downloadFile(currentFile?.path)
|
||||
setCurrentFile(undefined)
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (currentFile) {
|
||||
deleteFile(currentFile)
|
||||
setCurrentFile(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={!(files$.observed) || isPending}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Backdrop>
|
||||
<Paper sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={() => setShowMenu(false)}
|
||||
>
|
||||
<Typography py={1} variant="h5" color="primary">
|
||||
{i18n.t('archiveTitle')}
|
||||
</Typography>
|
||||
@@ -191,6 +224,12 @@ export default function Downloaded() {
|
||||
{selectable.length === 0 && 'No files found'}
|
||||
{selectable.map((file, idx) => (
|
||||
<ListItem
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentFile(file)
|
||||
setMenuPos({ x: e.clientX, y: e.clientY })
|
||||
setShowMenu(true)
|
||||
}}
|
||||
key={idx}
|
||||
secondaryAction={
|
||||
<div>
|
||||
@@ -202,17 +241,10 @@ export default function Downloaded() {
|
||||
</Typography>
|
||||
}
|
||||
{!file.isDirectory && <>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => downloadFile(file.path)}
|
||||
sx={{ marginLeft: 1.5 }}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<Checkbox
|
||||
edge="end"
|
||||
checked={file.selected}
|
||||
onChange={() => addSelected(file.path)}
|
||||
onChange={() => addSelected(file.name)}
|
||||
/>
|
||||
</>}
|
||||
</div>
|
||||
@@ -275,11 +307,15 @@ export default function Downloaded() {
|
||||
</ul>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
|
||||
<Button onClick={() => {
|
||||
deleteSelected()
|
||||
setOpenDialog(false)
|
||||
}} autoFocus
|
||||
<Button onClick={() => setOpenDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteSelected()
|
||||
setOpenDialog(false)
|
||||
}}
|
||||
autoFocus
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
@@ -288,3 +324,42 @@ export default function Downloaded() {
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const IconMenu: React.FC<{
|
||||
posX: number
|
||||
posY: number
|
||||
hide: boolean
|
||||
onDownload: () => void
|
||||
onDelete: () => void
|
||||
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
||||
return (
|
||||
<Paper sx={{
|
||||
width: 320,
|
||||
maxWidth: '100%',
|
||||
position: 'absolute',
|
||||
top: posY,
|
||||
left: posX,
|
||||
display: hide ? 'none' : 'block',
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
}}>
|
||||
<MenuList>
|
||||
<MenuItem onClick={onDownload}>
|
||||
<ListItemIcon>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
Download
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onDelete}>
|
||||
<ListItemIcon>
|
||||
<DeleteForeverIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
Delete
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
9
frontend/src/views/Terminal.tsx
Normal file
9
frontend/src/views/Terminal.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import LogTerminal from '../components/LogTerminal'
|
||||
|
||||
const Terminal: React.FC = () => {
|
||||
return (
|
||||
<LogTerminal />
|
||||
)
|
||||
}
|
||||
|
||||
export default Terminal
|
||||
@@ -1,10 +1,12 @@
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import million from 'million/compiler'
|
||||
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [
|
||||
million.vite({ auto: true }),
|
||||
react(),
|
||||
ViteYaml(),
|
||||
],
|
||||
|
||||
9
go.mod
9
go.mod
@@ -9,19 +9,28 @@ require (
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
|
||||
github.com/reactivex/rxgo/v2 v2.5.0
|
||||
golang.org/x/sys v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v4 v4.0.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods v1.12.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/objx v0.1.0 // indirect
|
||||
github.com/stretchr/testify v1.4.0 // indirect
|
||||
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 // indirect
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/net v0.19.0 // indirect
|
||||
golang.org/x/tools v0.16.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
|
||||
40
go.sum
40
go.sum
@@ -1,5 +1,11 @@
|
||||
github.com/cenkalti/backoff/v4 v4.0.0 h1:6VeaLF9aI+MAUQ95106HwWzYZgJJpZ4stumjj6RFYAU=
|
||||
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
|
||||
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
@@ -14,26 +20,58 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/reactivex/rxgo/v2 v2.5.0 h1:FhPgHwX9vKdNQB2gq9EPt+EKk9QrrzoeztGbEEnZam4=
|
||||
github.com/reactivex/rxgo/v2 v2.5.0/go.mod h1:bs4fVZxcb5ZckLIOeIeVH942yunJLWDABWGbrHAW+qU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775 h1:BLNsFR8l/hj/oGjnJXkd4Vi3s4kQD3/3x8HSAE4bzN0=
|
||||
github.com/teivah/onecontext v0.0.0-20200513185103-40f981bfd775/go.mod h1:XUZ4x3oGhWfiOnUvTslnKKs39AWUct3g3yJvXTQSJOQ=
|
||||
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
|
||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
|
||||
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
|
||||
4
main.go
4
main.go
@@ -76,8 +76,8 @@ func main() {
|
||||
|
||||
// if config file is found it will be merged with the current config struct
|
||||
if err := c.LoadFile(configFile); err != nil {
|
||||
log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
|
||||
log.Println(cli.BgRed, "config", cli.Reset, err)
|
||||
}
|
||||
|
||||
server.RunBlocking(host, port, frontend, localDatabasePath)
|
||||
server.RunBlocking(c.Host, c.Port, frontend, localDatabasePath)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
CurrentLogFile string
|
||||
LogPath string `yaml:"log_path"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
DownloadPath string `yaml:"downloadPath"`
|
||||
|
||||
@@ -4,13 +4,12 @@ import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
)
|
||||
|
||||
@@ -94,14 +93,14 @@ func (m *MemoryDB) All() *[]ProcessResponse {
|
||||
}
|
||||
|
||||
// WIP: Persist the database in a single file named "session.dat"
|
||||
func (m *MemoryDB) Persist() {
|
||||
func (m *MemoryDB) Persist() error {
|
||||
running := m.All()
|
||||
|
||||
sf := filepath.Join(config.Instance().SessionFilePath, "session.dat")
|
||||
|
||||
fd, err := os.Create(sf)
|
||||
if err != nil {
|
||||
log.Println(cli.Red, "Failed to persist session", cli.Reset)
|
||||
return errors.Join(errors.New("failed to persist session"), err)
|
||||
}
|
||||
|
||||
session := Session{
|
||||
@@ -110,14 +109,14 @@ func (m *MemoryDB) Persist() {
|
||||
|
||||
err = gob.NewEncoder(fd).Encode(session)
|
||||
if err != nil {
|
||||
log.Println(cli.Red, "Failed to persist session", cli.Reset)
|
||||
return errors.Join(errors.New("failed to persist session"), err)
|
||||
}
|
||||
|
||||
log.Println(cli.BgBlue, "Successfully serialized session", cli.Reset)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WIP: Restore a persisted state
|
||||
func (m *MemoryDB) Restore() {
|
||||
func (m *MemoryDB) Restore(logger *slog.Logger) {
|
||||
fd, err := os.Open("session.dat")
|
||||
if err != nil {
|
||||
return
|
||||
@@ -138,6 +137,7 @@ func (m *MemoryDB) Restore() {
|
||||
Progress: proc.Progress,
|
||||
Output: proc.Output,
|
||||
Params: proc.Params,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
m.table.Store(proc.Id, restored)
|
||||
@@ -146,6 +146,4 @@ func (m *MemoryDB) Restore() {
|
||||
go restored.Start()
|
||||
}
|
||||
}
|
||||
|
||||
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
)
|
||||
|
||||
@@ -19,7 +17,7 @@ func NewMessageQueue() *MessageQueue {
|
||||
size := config.Instance().QueueSize
|
||||
|
||||
if size <= 0 {
|
||||
log.Fatalln("invalid queue size")
|
||||
panic("invalid queue size")
|
||||
}
|
||||
|
||||
return &MessageQueue{
|
||||
|
||||
@@ -3,12 +3,11 @@ package internal
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
)
|
||||
|
||||
@@ -19,7 +18,7 @@ type metadata struct {
|
||||
Type string `json:"_type"`
|
||||
}
|
||||
|
||||
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB, logger *slog.Logger) error {
|
||||
var (
|
||||
downloader = config.Instance().DownloaderPath
|
||||
cmd = exec.Command(downloader, req.URL, "-J")
|
||||
@@ -37,14 +36,14 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println(cli.BgRed, "Decoding metadata", cli.Reset, req.URL)
|
||||
logger.Info("decoding metadata", slog.String("url", req.URL))
|
||||
|
||||
err = json.NewDecoder(stdout).Decode(&m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println(cli.BgGreen, "Decoded metadata", cli.Reset, req.URL)
|
||||
logger.Info("decoded metadata", slog.String("url", req.URL))
|
||||
|
||||
if m.Type == "" {
|
||||
cmd.Wait()
|
||||
@@ -52,8 +51,10 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
}
|
||||
|
||||
if m.Type == "playlist" {
|
||||
log.Println(
|
||||
cli.BgGreen, "Playlist detected", cli.Reset, m.Count, "entries",
|
||||
logger.Info(
|
||||
"playlist detected",
|
||||
slog.String("url", req.URL),
|
||||
slog.Int("count", m.Count),
|
||||
)
|
||||
|
||||
for i, meta := range m.Entries {
|
||||
@@ -90,11 +91,14 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
proc := &Process{Url: req.URL, Params: req.Params}
|
||||
proc := &Process{
|
||||
Url: req.URL,
|
||||
Params: req.Params,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
mq.Publish(proc)
|
||||
log.Println("Sending new process to message queue", proc.Url)
|
||||
logger.Info("sending new process to message queue", slog.String("url", proc.Url))
|
||||
|
||||
err = cmd.Wait()
|
||||
return err
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -50,6 +51,7 @@ type Process struct {
|
||||
Progress DownloadProgress
|
||||
Output DownloadOutput
|
||||
proc *os.Process
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type DownloadOutput struct {
|
||||
@@ -89,16 +91,22 @@ func (p *Process) Start() {
|
||||
|
||||
buildFilename(&p.Output)
|
||||
|
||||
params := append([]string{
|
||||
params := []string{
|
||||
strings.Split(p.Url, "?list")[0], //no playlist
|
||||
"--newline",
|
||||
"--no-colors",
|
||||
"--no-playlist",
|
||||
"--progress-template",
|
||||
strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(template),
|
||||
"-o",
|
||||
fmt.Sprintf("%s/%s", out.Path, out.Filename),
|
||||
}, p.Params...)
|
||||
}
|
||||
|
||||
// if user asked to manually override the output path...
|
||||
if !(slices.Includes(params, "-P") || slices.Includes(params, "--paths")) {
|
||||
params = append(params, "-o")
|
||||
params = append(params, fmt.Sprintf("%s/%s", out.Path, out.Filename))
|
||||
}
|
||||
|
||||
params = append(params, p.Params...)
|
||||
|
||||
// ----------------- main block ----------------- //
|
||||
cmd := exec.Command(config.Instance().DownloaderPath, params...)
|
||||
@@ -106,13 +114,21 @@ func (p *Process) Start() {
|
||||
|
||||
r, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Panicln(err)
|
||||
p.Logger.Error(
|
||||
"failed to connect to stdout",
|
||||
slog.String("err", err.Error()),
|
||||
)
|
||||
panic(err)
|
||||
}
|
||||
scan := bufio.NewScanner(r)
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
log.Panicln(err)
|
||||
p.Logger.Error(
|
||||
"failed to start yt-dlp process",
|
||||
slog.String("err", err.Error()),
|
||||
)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
p.proc = cmd.Process
|
||||
@@ -151,10 +167,10 @@ func (p *Process) Start() {
|
||||
Speed: stdout.Speed,
|
||||
ETA: stdout.Eta,
|
||||
}
|
||||
log.Println(
|
||||
cli.BgGreen, "DL", cli.Reset,
|
||||
cli.BgBlue, p.getShortId(), cli.Reset,
|
||||
p.Url, stdout.Percentage,
|
||||
p.Logger.Info("progress",
|
||||
slog.String("id", p.getShortId()),
|
||||
slog.String("url", p.Url),
|
||||
slog.String("percentege", stdout.Percentage),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -175,12 +191,9 @@ func (p *Process) Complete() {
|
||||
ETA: 0,
|
||||
}
|
||||
|
||||
shortId := p.getShortId()
|
||||
|
||||
log.Println(
|
||||
cli.BgMagenta, "FINISH", cli.Reset,
|
||||
cli.BgBlue, shortId, cli.Reset,
|
||||
p.Url,
|
||||
p.Logger.Info("finished",
|
||||
slog.String("id", p.getShortId()),
|
||||
slog.String("url", p.Url),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,7 +210,7 @@ func (p *Process) Kill() error {
|
||||
}
|
||||
err = syscall.Kill(-pgid, syscall.SIGTERM)
|
||||
|
||||
log.Println("Killed process", p.Id)
|
||||
p.Logger.Info("killed process", slog.String("id", p.Id))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,6 +246,12 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
||||
p.Url,
|
||||
)
|
||||
|
||||
p.Logger.Info(
|
||||
"retrieving metadata",
|
||||
slog.String("caller", "getFormats"),
|
||||
slog.String("url", p.Url),
|
||||
)
|
||||
|
||||
go func() {
|
||||
decodingError = json.Unmarshal(stdout, &info)
|
||||
wg.Done()
|
||||
@@ -264,7 +283,11 @@ func (p *Process) SetMetadata() error {
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Println("Cannot retrieve info for", p.Url)
|
||||
p.Logger.Error("failed retrieving info",
|
||||
slog.String("id", p.getShortId()),
|
||||
slog.String("url", p.Url),
|
||||
slog.String("err", err.Error()),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -278,10 +301,9 @@ func (p *Process) SetMetadata() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println(
|
||||
cli.BgRed, "Metadata", cli.Reset,
|
||||
cli.BgBlue, p.getShortId(), cli.Reset,
|
||||
p.Url,
|
||||
p.Logger.Info("retrieving metadata",
|
||||
slog.String("id", p.getShortId()),
|
||||
slog.String("url", p.Url),
|
||||
)
|
||||
|
||||
err = json.NewDecoder(stdout).Decode(&info)
|
||||
|
||||
80
server/logging/handler.go
Normal file
80
server/logging/handler.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
ReadBufferSize: 1000,
|
||||
WriteBufferSize: 1000,
|
||||
}
|
||||
|
||||
func webSocket(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for msg := range logsObservable.Observe() {
|
||||
c.WriteJSON(msg.V)
|
||||
}
|
||||
}
|
||||
|
||||
func sse(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "SSE not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for msg := range logsObservable.Observe() {
|
||||
if msg.E != nil {
|
||||
http.Error(w, msg.E.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
b bytes.Buffer
|
||||
sb strings.Builder
|
||||
)
|
||||
|
||||
if err := json.NewEncoder(&b).Encode(msg.V); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sb.WriteString("event: log\n")
|
||||
sb.WriteString("data: " + b.String() + "\n\n")
|
||||
|
||||
fmt.Fprint(w, sb.String())
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyRouter() func(chi.Router) {
|
||||
return func(r chi.Router) {
|
||||
if config.Instance().RequireAuth {
|
||||
r.Use(middlewares.Authenticated)
|
||||
}
|
||||
r.Get("/ws", webSocket)
|
||||
r.Get("/sse", sse)
|
||||
}
|
||||
}
|
||||
29
server/logging/observable_logger.go
Normal file
29
server/logging/observable_logger.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/reactivex/rxgo/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
logsChan = make(chan rxgo.Item, 100)
|
||||
logsObservable = rxgo.
|
||||
FromChannel(logsChan, rxgo.WithBackPressureStrategy(rxgo.Drop)).
|
||||
BufferWithTime(rxgo.WithDuration(time.Millisecond * 500))
|
||||
)
|
||||
|
||||
type ObservableLogger struct{}
|
||||
|
||||
func NewObservableLogger() *ObservableLogger {
|
||||
return &ObservableLogger{}
|
||||
}
|
||||
|
||||
func (o *ObservableLogger) Write(p []byte) (n int, err error) {
|
||||
logsChan <- rxgo.Of(string(p))
|
||||
|
||||
n = len(p)
|
||||
err = nil
|
||||
|
||||
return
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,9 +12,10 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
mdb *internal.MemoryDB
|
||||
db *sql.DB
|
||||
mq *internal.MessageQueue
|
||||
mdb *internal.MemoryDB
|
||||
db *sql.DB
|
||||
mq *internal.MessageQueue
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
|
||||
@@ -24,6 +26,7 @@ func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
|
||||
Path: req.Path,
|
||||
Filename: req.Rename,
|
||||
},
|
||||
Logger: s.logger,
|
||||
}
|
||||
|
||||
id := s.mdb.Set(p)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||
@@ -8,10 +10,15 @@ import (
|
||||
)
|
||||
|
||||
// Dependency injection container.
|
||||
func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
|
||||
func Container(
|
||||
db *internal.MemoryDB,
|
||||
mq *internal.MessageQueue,
|
||||
logger *slog.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
mq: mq,
|
||||
db: db,
|
||||
mq: mq,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys"
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *internal.MemoryDB
|
||||
mq *internal.MessageQueue
|
||||
db *internal.MemoryDB
|
||||
mq *internal.MessageQueue
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type Running []internal.ProcessResponse
|
||||
@@ -34,6 +35,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
|
||||
Path: args.Path,
|
||||
Filename: args.Rename,
|
||||
},
|
||||
Logger: s.logger,
|
||||
}
|
||||
|
||||
s.db.Set(p)
|
||||
@@ -46,7 +48,7 @@ func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
|
||||
// Exec spawns a Process.
|
||||
// The result of the execution is the newly spawned process Id.
|
||||
func (s *Service) ExecPlaylist(args internal.DownloadRequest, result *string) error {
|
||||
err := internal.PlaylistDetect(args, s.mq, s.db)
|
||||
err := internal.PlaylistDetect(args, s.mq, s.db, s.logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,7 +71,7 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
|
||||
// Progess retrieves available format for a given resource
|
||||
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
|
||||
var err error
|
||||
p := internal.Process{Url: args.URL}
|
||||
p := internal.Process{Url: args.URL, Logger: s.logger}
|
||||
*meta, err = p.GetFormatsSync()
|
||||
return err
|
||||
}
|
||||
@@ -88,7 +90,7 @@ func (s *Service) Running(args NoArgs, running *Running) error {
|
||||
|
||||
// Kill kills a process given its id and remove it from the memoryDB
|
||||
func (s *Service) Kill(args string, killed *string) error {
|
||||
log.Println("Trying killing process with id", args)
|
||||
s.logger.Info("Trying killing process with id", slog.String("id", args))
|
||||
proc, err := s.db.Get(args)
|
||||
|
||||
if err != nil {
|
||||
@@ -106,7 +108,7 @@ func (s *Service) Kill(args string, killed *string) error {
|
||||
// KillAll kills all process unconditionally and removes them from
|
||||
// the memory db
|
||||
func (s *Service) KillAll(args NoArgs, killed *string) error {
|
||||
log.Println("Killing all spawned processes", args)
|
||||
s.logger.Info("Killing all spawned processes")
|
||||
keys := s.db.Keys()
|
||||
var err error
|
||||
for _, key := range *keys {
|
||||
@@ -125,7 +127,7 @@ func (s *Service) KillAll(args NoArgs, killed *string) error {
|
||||
|
||||
// Remove a process from the db rendering it unusable if active
|
||||
func (s *Service) Clear(args string, killed *string) error {
|
||||
log.Println("Clearing process with id", args)
|
||||
s.logger.Info("Clearing process with id", slog.String("id", args))
|
||||
s.db.Delete(args)
|
||||
return nil
|
||||
}
|
||||
@@ -148,7 +150,7 @@ func (s *Service) DirectoryTree(args NoArgs, tree *[]string) error {
|
||||
|
||||
// Updates the yt-dlp binary using its builtin function
|
||||
func (s *Service) UpdateExecutable(args NoArgs, updated *bool) error {
|
||||
log.Println("Updating yt-dlp executable to the latest release")
|
||||
s.logger.Info("Updating yt-dlp executable to the latest release")
|
||||
err := updater.UpdateExecutable()
|
||||
if err != nil {
|
||||
*updated = true
|
||||
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/rpc"
|
||||
"os"
|
||||
@@ -14,12 +15,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutils"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging"
|
||||
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
|
||||
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
|
||||
type serverConfig struct {
|
||||
frontend fs.FS
|
||||
logger *slog.Logger
|
||||
host string
|
||||
port int
|
||||
mdb *internal.MemoryDB
|
||||
@@ -38,16 +40,24 @@ type serverConfig struct {
|
||||
|
||||
func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
|
||||
var mdb internal.MemoryDB
|
||||
mdb.Restore()
|
||||
|
||||
logger := slog.New(
|
||||
slog.NewTextHandler(
|
||||
io.MultiWriter(os.Stdout, logging.NewObservableLogger()),
|
||||
nil,
|
||||
),
|
||||
)
|
||||
|
||||
mdb.Restore(logger)
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
logger.Error("failed to open database", slog.String("err", err.Error()))
|
||||
}
|
||||
|
||||
err = dbutils.AutoMigrate(context.Background(), db)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
logger.Error("failed to init database", slog.String("err", err.Error()))
|
||||
}
|
||||
|
||||
mq := internal.NewMessageQueue()
|
||||
@@ -55,6 +65,7 @@ func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
|
||||
|
||||
srv := newServer(serverConfig{
|
||||
frontend: frontend,
|
||||
logger: logger,
|
||||
host: host,
|
||||
port: port,
|
||||
mdb: &mdb,
|
||||
@@ -63,13 +74,17 @@ func RunBlocking(host string, port int, frontend fs.FS, dbPath string) {
|
||||
})
|
||||
|
||||
go gracefulShutdown(srv, &mdb)
|
||||
go autoPersist(time.Minute*5, &mdb)
|
||||
go autoPersist(time.Minute*5, &mdb, logger)
|
||||
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
logger.Info("yt-dlp-webui started", slog.Int("port", port))
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
logger.Warn("http server stopped", slog.String("err", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func newServer(c serverConfig) *http.Server {
|
||||
service := ytdlpRPC.Container(c.mdb, c.mq)
|
||||
service := ytdlpRPC.Container(c.mdb, c.mq, c.logger)
|
||||
rpc.Register(service)
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -89,11 +104,10 @@ func newServer(c serverConfig) *http.Server {
|
||||
})
|
||||
|
||||
r.Use(corsMiddleware.Handler)
|
||||
r.Use(middleware.Logger)
|
||||
// use in dev
|
||||
// r.Use(middleware.Logger)
|
||||
|
||||
app := http.FileServer(http.FS(c.frontend))
|
||||
|
||||
r.Mount("/", app)
|
||||
r.Mount("/", http.FileServer(http.FS(c.frontend)))
|
||||
|
||||
// Archive routes
|
||||
r.Route("/archive", func(r chi.Router) {
|
||||
@@ -118,6 +132,9 @@ func newServer(c serverConfig) *http.Server {
|
||||
// REST API handlers
|
||||
r.Route("/api/v1", rest.ApplyRouter(c.db, c.mdb, c.mq))
|
||||
|
||||
// Logging
|
||||
r.Route("/log", logging.ApplyRouter())
|
||||
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", c.host, c.port),
|
||||
Handler: r,
|
||||
@@ -133,7 +150,7 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Println("shutdown signal received")
|
||||
slog.Info("shutdown signal received")
|
||||
|
||||
defer func() {
|
||||
db.Persist()
|
||||
@@ -143,9 +160,15 @@ func gracefulShutdown(srv *http.Server, db *internal.MemoryDB) {
|
||||
}()
|
||||
}
|
||||
|
||||
func autoPersist(d time.Duration, db *internal.MemoryDB) {
|
||||
func autoPersist(d time.Duration, db *internal.MemoryDB, logger *slog.Logger) {
|
||||
for {
|
||||
db.Persist()
|
||||
if err := db.Persist(); err != nil {
|
||||
logger.Info(
|
||||
"failed to persisted session",
|
||||
slog.String("err", err.Error()),
|
||||
)
|
||||
}
|
||||
logger.Info("sucessfully persisted session")
|
||||
time.Sleep(d)
|
||||
}
|
||||
}
|
||||
|
||||
55
server/utils/logrotate.go
Normal file
55
server/utils/logrotate.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||
)
|
||||
|
||||
func LogRotate() (*os.File, error) {
|
||||
logs := findLogs()
|
||||
|
||||
for _, log := range logs {
|
||||
logfd, err := os.Open(log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gzWriter, err := os.Create(log + ".gz")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(gzWriter, logfd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
logfile := time.Now().String() + ".log"
|
||||
config.Instance().CurrentLogFile = logfile
|
||||
|
||||
return os.Create(logfile)
|
||||
}
|
||||
|
||||
func findLogs() []string {
|
||||
var (
|
||||
logfiles []string
|
||||
root = config.Instance().LogPath
|
||||
)
|
||||
|
||||
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.Ext(d.Name()) == ".log" {
|
||||
logfiles = append(logfiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return logfiles
|
||||
}
|
||||
Reference in New Issue
Block a user