Compare commits

..

16 Commits

Author SHA1 Message Date
d9cb018132 ready for v3.2.3 2024-12-12 10:20:57 +01:00
Marco Piovanello
ac077ea1e1 upgraded react to v19 (#232) 2024-12-12 09:49:20 +01:00
f29d719df0 fixed never awaited cookies template promise 2024-12-12 09:33:09 +01:00
Marco Piovanello
6adfa71fde custom path based frontend (#231) 2024-12-05 10:00:15 +01:00
0946d374e3 more examples 2024-11-23 09:53:44 +01:00
Marco Piovanello
f68c29f838 Update README.md 2024-11-19 11:43:42 +01:00
Marco Piovanello
c46e39e736 code refactoring (#227) 2024-11-19 11:36:36 +01:00
Marco Piovanello
2885d6b5d8 Update README.md 2024-11-17 08:58:08 +01:00
Marco Piovanello
ab7932ae92 Editable templates (#225)
* editable templates

* removed unused import
2024-11-15 14:24:44 +01:00
6c9118f67e added dockerignore 2024-11-12 11:39:21 +01:00
34c78c7e2d ready for 3.2.2 2024-11-12 11:32:50 +01:00
Marco Piovanello
01c6edef74 jotai migration (#221) 2024-11-12 11:31:25 +01:00
4a87ea559a prevent downloading playlist with format selection 2024-11-10 15:32:17 +01:00
Andrási István
846fb294d0 Fix module name to match major version v3. Simplify makefile. (#213)
Co-authored-by: Marco Piovanello <35533749+marcopeocchi@users.noreply.github.com>
2024-11-10 13:59:40 +01:00
Dusk
baa25afa27 fix: manual installation (#220) 2024-11-10 08:53:30 +01:00
Néfix Estrada
b0dac0adda fix(nix): fix package build (#208) 2024-10-13 13:38:17 +02:00
77 changed files with 1472 additions and 1739 deletions

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
.pre-commit-config.yaml
.direnv/
result/
result
dist
.pnpm-debug.log
node_modules
.env
*.mp4
*.ytdl
*.part
*.db
downloads
.DS_Store
build/
yt-dlp-webui
session.dat
config.yml
cookies.txt
__debug*
ui/
.idea
frontend/.pnp.cjs
frontend/.pnp.loader.mjs
frontend/.yarn/install-state.gz
.db.lock
livestreams.dat
.git

View File

@@ -4,17 +4,15 @@ default:
go run main.go go run main.go
fe: fe:
cd frontend && pnpm build cd frontend && pnpm install && pnpm build
dev: dev:
cd frontend && pnpm dev cd frontend && pnpm install && pnpm dev
all: all: fe
$(MAKE) fe && cd ..
CGO_ENABLED=0 go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch: multiarch: fe
$(MAKE) fe
mkdir -p build mkdir -p build
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go

View File

@@ -1,3 +1,7 @@
> [!NOTE]
> A poll is up to decide the future of yt-dlp-web-ui frontend! If you're interested you can take part.
> https://github.com/marcopeocchi/yt-dlp-web-ui/discussions/223
# yt-dlp Web UI # yt-dlp Web UI
A not so terrible web ui for yt-dlp. A not so terrible web ui for yt-dlp.
@@ -151,6 +155,8 @@ Usage yt-dlp-webui:
session file path (default ".") session file path (default ".")
-user string -user string
Username required for auth Username required for auth
-web string
frontend web resources path
``` ```
### Config file ### Config file
@@ -180,7 +186,7 @@ password: my_random_secret
queue_size: 4 # min. 2 queue_size: 4 # min. 2
# [optional] Full path to the yt-dlp (default: "yt-dlp") # [optional] Full path to the yt-dlp (default: "yt-dlp")
downloaderPath: /usr/local/bin/yt-dlp #downloaderPath: /usr/local/bin/yt-dlp
# [optional] Enable file based logging with rotation (default: false) # [optional] Enable file based logging with rotation (default: false)
#enable_file_logging: false #enable_file_logging: false
@@ -193,6 +199,9 @@ downloaderPath: /usr/local/bin/yt-dlp
# [optional] Path where the sqlite database will be created/opened (default: "./local.db") # [optional] Path where the sqlite database will be created/opened (default: "./local.db")
#local_database_path #local_database_path
# [optional] Path where a custom frontend will be loaded (instead of the embedded one)
#frontend_path: ./web/solid-frontend
``` ```
### Systemd integration ### Systemd integration
@@ -258,6 +267,22 @@ It is **planned** to also expose a **gRPC** server.
For more information open an issue on GitHub and I will provide more info ASAP. For more information open an issue on GitHub and I will provide more info ASAP.
## Custom frontend
To load a custom frontend you need to specify its path either in the config file ([see config file](#config-file)) or via flags.
The frontend needs to follow this structure:
```
path/to/my/frontend
├── assets
│ ├── js-chunk-1.js (example)
│ ├── js-chunk-2.js (example)
│ ├── style.css (example)
└── index.html
```
`assets` is where the resources will be loaded.
`index.html` is the entrypoint.
## Nix ## Nix
This repo adds support for Nix(OS) in various ways through a `flake-parts` flake. This repo adds support for Nix(OS) in various ways through a `flake-parts` flake.
For more info, please refer to the [official documentation](https://nixos.org/learn/). For more info, please refer to the [official documentation](https://nixos.org/learn/).

View File

@@ -0,0 +1,27 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://app:3033;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
client_max_body_size 20000m;
proxy_connect_timeout 5000;
proxy_send_timeout 5000;
proxy_read_timeout 5000;
send_timeout 5000;
}
}

View File

@@ -0,0 +1,15 @@
services:
app:
image: marcobaobao/yt-dlp-webui
volumes:
- ./downloads:/downloads
restart: unless-stopped
nginx:
image: nginx:alpine
restart: unless-stopped
volumes:
- ./app.conf:/etc/nginx/conf.d/app.conf
depends_on:
- app
ports:
- 80:80

View File

@@ -1,39 +1,38 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "3.2.1", "version": "3.2.3",
"description": "Frontend compontent of yt-dlp-webui", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build" "build": "vite build"
}, },
"author": "marcopeocchi", "author": "marcopeocchi",
"license": "MPL-2.0", "license": "GPL-3.0-only",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.4", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.11.5", "@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.0.13", "@fontsource/roboto": "^5.0.13",
"@fontsource/roboto-mono": "^5.0.18", "@fontsource/roboto-mono": "^5.0.18",
"@mui/icons-material": "^5.15.16", "@mui/icons-material": "^6.2.0",
"@mui/material": "^5.15.16", "@mui/material": "^6.2.0",
"fp-ts": "^2.16.5", "fp-ts": "^2.16.5",
"react": "^18.3.1", "react": "^19.0.0",
"react-dom": "^18.3.1", "react-dom": "^19.0.0",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"react-virtuoso": "^4.7.11", "react-virtuoso": "^4.7.11",
"recoil": "^0.7.7", "jotai": "^2.10.3",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/node": "^20.14.2", "@types/node": "^20.14.2",
"@types/react": "^18.3.3", "@types/react": "^19.0.1",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^19.0.2",
"@types/react-helmet": "^6.1.11", "@types/react-helmet": "^6.1.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.2",
"million": "^3.1.11", "typescript": "^5.7.2",
"typescript": "^5.4.5", "vite": "^6.0.3"
"vite": "^5.2.11"
} }
} }

1697
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import { RouterProvider } from 'react-router-dom' import { RouterProvider } from 'react-router-dom'
import { RecoilRoot } from 'recoil' import { Provider } from 'jotai'
import { router } from './router' import { router } from './router'
export function App() { export function App() {
return ( return (
<RecoilRoot> <Provider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</RecoilRoot> </Provider>
) )
} }

View File

@@ -16,10 +16,9 @@ import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors' import { grey, red } from '@mui/material/colors'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { settingsState } from './atoms/settings' import { settingsState } from './atoms/settings'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
@@ -29,22 +28,27 @@ import SocketSubscriber from './components/SocketSubscriber'
import ThemeToggler from './components/ThemeToggler' import ThemeToggler from './components/ThemeToggler'
import { useI18n } from './hooks/useI18n' import { useI18n } from './hooks/useI18n'
import Toaster from './providers/ToasterProvider' import Toaster from './providers/ToasterProvider'
import { useAtomValue } from 'jotai'
import { getAccentValue } from './utils'
export default function Layout() { export default function Layout() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const settings = useRecoilValue(settingsState) const settings = useAtomValue(settingsState)
const mode = settings.theme const mode = settings.theme
const theme = useMemo(() => const theme = useMemo(() =>
createTheme({ createTheme({
palette: { palette: {
mode: settings.theme, mode: settings.theme,
primary: {
main: getAccentValue(settings.accent)
},
background: { background: {
default: settings.theme === 'light' ? grey[50] : '#121212' default: settings.theme === 'light' ? grey[50] : '#121212'
}, },
}, },
}), [settings.theme] }), [settings.theme, settings.accent]
) )
const toggleDrawer = () => setOpen(state => !state) const toggleDrawer = () => setOpen(state => !state)

View File

@@ -65,6 +65,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
german: german:
urlInput: Video URL urlInput: Video URL
statusTitle: Status statusTitle: Status
@@ -126,6 +127,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
french: french:
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
statusTitle: Statut statusTitle: Statut
@@ -189,6 +191,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
italian: italian:
urlInput: URL Video (uno per linea) urlInput: URL Video (uno per linea)
statusTitle: Stato statusTitle: Stato
@@ -249,6 +252,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
chinese: chinese:
urlInput: 视频 URL urlInput: 视频 URL
statusTitle: 状态 statusTitle: 状态
@@ -310,6 +314,7 @@ languages:
如果直播已经开始,那么依然可以下载,但是不会记录下载进度。 如果直播已经开始,那么依然可以下载,但是不会记录下载进度。
直播开始后,将会转移到下载页面 直播开始后,将会转移到下载页面
livestreamExperimentalWarning: 实验性功能可能存在未知Bug请谨慎使用 livestreamExperimentalWarning: 实验性功能可能存在未知Bug请谨慎使用
accentSelect: 'Accent'
spanish: spanish:
urlInput: URL de YouTube u otro servicio compatible urlInput: URL de YouTube u otro servicio compatible
statusTitle: Estado statusTitle: Estado
@@ -369,6 +374,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
russian: russian:
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
statusTitle: Статус statusTitle: Статус
@@ -428,6 +434,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -487,6 +494,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
@@ -547,6 +555,7 @@ languages:
すでに開始されているライブストリームが提供された場合、ダウンロードは継続されますが進行状況は追跡されません。 すでに開始されているライブストリームが提供された場合、ダウンロードは継続されますが進行状況は追跡されません。
ライブストリームが開始されると、ダウンロードページに移動されます。 ライブストリームが開始されると、ダウンロードページに移動されます。
livestreamExperimentalWarning: この機能は実験的なものです。何かが壊れるかもしれません! livestreamExperimentalWarning: この機能は実験的なものです。何かが壊れるかもしれません!
accentSelect: 'Accent'
catalan: catalan:
urlInput: URL de YouTube o d'un altre servei compatible urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat statusTitle: Estat
@@ -606,6 +615,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
ukrainian: ukrainian:
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
statusTitle: Статус statusTitle: Статус
@@ -665,6 +675,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
polish: polish:
urlInput: Adres URL YouTube lub innej obsługiwanej usługi urlInput: Adres URL YouTube lub innej obsługiwanej usługi
statusTitle: Status statusTitle: Status
@@ -724,6 +735,7 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'
swedish: swedish:
urlInput: Videolänk (en per rad) urlInput: Videolänk (en per rad)
statusTitle: Status statusTitle: Status
@@ -789,3 +801,4 @@ languages:
If an already started livestream is provided it will be still downloaded but its progress will not be tracked. If an already started livestream is provided it will be still downloaded but its progress will not be tracked.
Once started the livestream will be migrated to the downloads page. Once started the livestream will be migrated to the downloads page.
livestreamExperimentalWarning: This feature is still experimental. Something might break! livestreamExperimentalWarning: This feature is still experimental. Something might break!
accentSelect: 'Accent'

View File

@@ -1,50 +1,40 @@
import { getOrElse } from 'fp-ts/lib/Either' import { getOrElse } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types' import { CustomTemplate } from '../types'
import { serverSideCookiesState, serverURL } from './settings' import { serverSideCookiesState, serverURL } from './settings'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
export const cookiesTemplateState = selector({ export const cookiesTemplateState = atom<Promise<string>>(async (get) =>
key: 'cookiesTemplateState', await get(serverSideCookiesState)
get: ({ get }) => get(serverSideCookiesState)
? '--cookies=cookies.txt' ? '--cookies=cookies.txt'
: '' : ''
}) )
export const customArgsState = atom({ export const customArgsState = atomWithStorage(
key: 'customArgsState', 'customArgs',
default: localStorage.getItem('customArgs') ?? '', localStorage.getItem('customArgs') ?? ''
effects: [ )
({ onSet }) => onSet(e => localStorage.setItem('customArgs', e))
]
})
export const filenameTemplateState = atom({ export const filenameTemplateState = atomWithStorage(
key: 'filenameTemplateState', 'lastFilenameTemplate',
default: localStorage.getItem('lastFilenameTemplate') ?? '', localStorage.getItem('lastFilenameTemplate') ?? ''
effects: [ )
({ onSet }) => onSet(e => localStorage.setItem('lastFilenameTemplate', e))
]
})
export const downloadTemplateState = selector({ export const downloadTemplateState = atom<Promise<string>>(async (get) =>
key: 'downloadTemplateState', `${get(customArgsState)} ${await get(cookiesTemplateState)}`
get: ({ get }) => .replace(/ +/g, ' ')
`${get(customArgsState)} ${get(cookiesTemplateState)}` .trim()
.replace(/ +/g, ' ') )
.trim()
})
export const savedTemplatesState = selector<CustomTemplate[]>({ export const savedTemplatesState = atom<Promise<CustomTemplate[]>>(async (get) => {
key: 'savedTemplatesState', const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
get: async ({ get }) => { const either = await task()
const task = ffetch<CustomTemplate[]>(`${get(serverURL)}/api/v1/template/all`)
const either = await task()
return pipe( return pipe(
either, either,
getOrElse(() => new Array<CustomTemplate>()) getOrElse(() => new Array<CustomTemplate>())
) )
} }
}) )

View File

@@ -1,22 +1,13 @@
import * as O from 'fp-ts/Option' import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { atom, selector } from 'recoil'
import { RPCResult } from '../types' import { RPCResult } from '../types'
import { atom } from 'jotai'
export const downloadsState = atom<O.Option<RPCResult[]>>({ export const downloadsState = atom<O.Option<RPCResult[]>>(O.none)
key: 'downloadsState',
default: O.none
})
export const loadingDownloadsState = selector<boolean>({ export const loadingDownloadsState = atom<boolean>((get) => O.isNone(get(downloadsState)))
key: 'loadingDownloadsState',
get: ({ get }) => O.isNone(get(downloadsState))
})
export const activeDownloadsState = selector<RPCResult[]>({ export const activeDownloadsState = atom<RPCResult[]>((get) => pipe(
key: 'activeDownloadsState', get(downloadsState),
get: ({ get }) => pipe( O.getOrElse(() => new Array<RPCResult>())
get(downloadsState), ))
O.getOrElse(() => new Array<RPCResult>())
)
})

View File

@@ -1,9 +1,5 @@
import { selector } from 'recoil' import { atom } from 'jotai'
import I18nBuilder from '../lib/intl' import I18nBuilder from '../lib/intl'
import { languageState } from './settings' import { languageState } from './settings'
export const i18nBuilderState = selector({ export const i18nBuilderState = atom((get) => new I18nBuilder(get(languageState)))
key: 'i18nBuilderState',
get: ({ get }) => new I18nBuilder(get(languageState)),
dangerouslyAllowMutability: true,
})

View File

@@ -1,23 +1,17 @@
import { atom, selector } from 'recoil' import { atom } from 'jotai'
import { RPCClient } from '../lib/rpcClient' import { RPCClient } from '../lib/rpcClient'
import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings' import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings'
import { atomWithStorage } from 'jotai/utils'
export const rpcClientState = selector({ export const rpcClientState = atom((get) =>
key: 'rpcClientState', new RPCClient(
get: ({ get }) => get(rpcHTTPEndpoint),
new RPCClient( get(rpcWebSocketEndpoint),
get(rpcHTTPEndpoint), localStorage.getItem('token') ?? ''
get(rpcWebSocketEndpoint), ),
localStorage.getItem('token') ?? '' )
),
dangerouslyAllowMutability: true,
})
export const rpcPollingTimeState = atom({ export const rpcPollingTimeState = atomWithStorage(
key: 'rpcPollingTimeState', 'rpc-polling-time',
default: Number(localStorage.getItem('rpc-polling-time')) || 1000, Number(localStorage.getItem('rpc-polling-time')) || 1000
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('rpc-polling-time', a.toString()))
]
})

View File

@@ -1,8 +1,9 @@
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { matchW } from 'fp-ts/lib/TaskEither' import { matchW } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { prefersDarkMode } from '../utils' import { prefersDarkMode } from '../utils'
import { atomWithStorage } from 'jotai/utils'
import { atom } from 'jotai'
export const languages = [ export const languages = [
'english', 'english',
@@ -25,11 +26,15 @@ export type Language = (typeof languages)[number]
export type Theme = 'light' | 'dark' | 'system' export type Theme = 'light' | 'dark' | 'system'
export type ThemeNarrowed = 'light' | 'dark' export type ThemeNarrowed = 'light' | 'dark'
export const accents = ['default', 'red'] as const
export type Accent = (typeof accents)[number]
export interface SettingsState { export interface SettingsState {
serverAddr: string serverAddr: string
serverPort: number serverPort: number
language: Language language: Language
theme: ThemeNarrowed theme: ThemeNarrowed
accent: Accent
cliArgs: string cliArgs: string
formatSelection: boolean formatSelection: boolean
fileRenaming: boolean fileRenaming: boolean
@@ -40,194 +45,131 @@ export interface SettingsState {
appTitle: string appTitle: string
} }
export const languageState = atom<Language>({ export const languageState = atomWithStorage<Language>(
key: 'languageState', 'language',
default: localStorage.getItem('language') as Language || 'english', localStorage.getItem('language') as Language || 'english'
effects: [ )
({ onSet }) =>
onSet(l => localStorage.setItem('language', l.toString()))
]
})
export const themeState = atom<Theme>({ export const themeState = atomWithStorage<Theme>(
key: 'themeStateState', 'theme',
default: localStorage.getItem('theme') as Theme || 'system', localStorage.getItem('theme') as Theme || 'system'
effects: [ )
({ onSet }) =>
onSet(l => localStorage.setItem('theme', l.toString()))
]
})
export const serverAddressState = atom<string>({ export const serverAddressState = atomWithStorage<string>(
key: 'serverAddressState', 'server-addr',
default: localStorage.getItem('server-addr') || window.location.hostname, localStorage.getItem('server-addr') || window.location.hostname
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('server-addr', a.toString()))
]
})
export const serverPortState = atom<number>({ export const serverPortState = atomWithStorage<number>(
key: 'serverPortState', 'server-port',
default: Number(localStorage.getItem('server-port')) || Number(window.location.port), Number(localStorage.getItem('server-port')) || Number(window.location.port)
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('server-port', a.toString()))
]
})
export const latestCliArgumentsState = atom<string>({ export const latestCliArgumentsState = atomWithStorage<string>(
key: 'latestCliArgumentsState', 'cli-args',
default: localStorage.getItem('cli-args') || '--no-mtime', localStorage.getItem('cli-args') || '--no-mtime'
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('cli-args', a.toString()))
]
})
export const formatSelectionState = atom({ export const formatSelectionState = atomWithStorage(
key: 'formatSelectionState', 'format-selection',
default: localStorage.getItem('format-selection') === "true", localStorage.getItem('format-selection') === 'true'
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('format-selection', a.toString()))
]
})
export const fileRenamingState = atom({ export const fileRenamingState = atomWithStorage(
key: 'fileRenamingState', 'file-renaming',
default: localStorage.getItem('file-renaming') === "true", localStorage.getItem('file-renaming') === 'true'
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('file-renaming', a.toString()))
]
})
export const pathOverridingState = atom({ export const pathOverridingState = atomWithStorage(
key: 'pathOverridingState', 'path-overriding',
default: localStorage.getItem('path-overriding') === "true", localStorage.getItem('path-overriding') === 'true'
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('path-overriding', a.toString()))
]
})
export const enableCustomArgsState = atom({ export const enableCustomArgsState = atomWithStorage(
key: 'enableCustomArgsState', 'enable-custom-args',
default: localStorage.getItem('enable-custom-args') === "true", localStorage.getItem('enable-custom-args') === 'true'
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('enable-custom-args', a.toString()))
]
})
export const listViewState = atom({ export const listViewState = atomWithStorage(
key: 'listViewState', 'listview',
default: localStorage.getItem('listview') === "true", localStorage.getItem('listview') === 'true'
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('listview', a.toString()))
]
})
export const servedFromReverseProxyState = atom({ export const servedFromReverseProxyState = atomWithStorage(
key: 'servedFromReverseProxyState', 'reverseProxy',
default: localStorage.getItem('reverseProxy') === "true" || window.location.port == "", localStorage.getItem('reverseProxy') === 'true' || window.location.port == ''
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxy', a.toString()))
]
})
export const servedFromReverseProxySubDirState = atom<string>({ export const servedFromReverseProxySubDirState = atomWithStorage<string>(
key: 'servedFromReverseProxySubDirState', 'reverseProxySubDir',
default: localStorage.getItem('reverseProxySubDir') ?? '', localStorage.getItem('reverseProxySubDir') ?? ''
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('reverseProxySubDir', a))
]
})
export const appTitleState = atom({ export const appTitleState = atomWithStorage(
key: 'appTitleState', 'appTitle',
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI', localStorage.getItem('appTitle') ?? 'yt-dlp Web UI'
effects: [ )
({ onSet }) =>
onSet(a => localStorage.setItem('appTitle', a.toString()))
]
})
export const serverAddressAndPortState = selector({ export const serverAddressAndPortState = atom((get) => {
key: 'serverAddressAndPortState', if (get(servedFromReverseProxySubDirState)) {
get: ({ get }) => { return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
if (get(servedFromReverseProxySubDirState)) {
return `${get(serverAddressState)}/${get(servedFromReverseProxySubDirState)}/`
}
if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
}
return `${get(serverAddressState)}:${get(serverPortState)}`
} }
}) if (get(servedFromReverseProxyState)) {
return `${get(serverAddressState)}`
export const serverURL = selector({
key: 'serverURL',
get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressAndPortState)}`
})
export const rpcWebSocketEndpoint = selector({
key: 'rpcWebSocketEndpoint',
get: ({ get }) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
} }
return `${get(serverAddressState)}:${get(serverPortState)}`
}) })
export const rpcHTTPEndpoint = selector({ export const serverURL = atom((get) =>
key: 'rpcHTTPEndpoint', `${window.location.protocol}//${get(serverAddressAndPortState)}`
get: ({ get }) => { )
const proto = window.location.protocol
return `${proto}//${get(serverAddressAndPortState)}/rpc/http` export const rpcWebSocketEndpoint = atom((get) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
}
)
export const rpcHTTPEndpoint = atom((get) => {
const proto = window.location.protocol
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
}
)
export const serverSideCookiesState = atom<Promise<string>>(async (get) => await pipe(
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`),
matchW(
() => '',
(r) => r.cookies
)
)())
const themeSelector = atom<ThemeNarrowed>((get) => {
const theme = get(themeState)
if ((theme === 'system' && prefersDarkMode()) || theme === 'dark') {
return 'dark'
} }
return 'light'
}) })
export const serverSideCookiesState = selector<string>({ export const accentState = atomWithStorage<Accent>(
key: 'serverSideCookiesState', 'accent-color',
get: async ({ get }) => await pipe( localStorage.getItem('accent-color') as Accent ?? 'default',
ffetch<Readonly<{ cookies: string }>>(`${get(serverURL)}/api/v1/cookies`), )
matchW(
() => '',
(r) => r.cookies
)
)()
})
const themeSelector = selector<ThemeNarrowed>({ export const settingsState = atom<SettingsState>((get) => ({
key: 'themeSelector', serverAddr: get(serverAddressState),
get: ({ get }) => { serverPort: get(serverPortState),
const theme = get(themeState) language: get(languageState),
if ((theme === 'system' && prefersDarkMode()) || theme === 'dark') { theme: get(themeSelector),
return 'dark' accent: get(accentState),
} cliArgs: get(latestCliArgumentsState),
return 'light' formatSelection: get(formatSelectionState),
} fileRenaming: get(fileRenamingState),
}) pathOverriding: get(pathOverridingState),
enableCustomArgs: get(enableCustomArgsState),
export const settingsState = selector<SettingsState>({ listView: get(listViewState),
key: 'settingsState', servedFromReverseProxy: get(servedFromReverseProxyState),
get: ({ get }) => ({ appTitle: get(appTitleState)
serverAddr: get(serverAddressState),
serverPort: get(serverPortState),
language: get(languageState),
theme: get(themeSelector),
cliArgs: get(latestCliArgumentsState),
formatSelection: get(formatSelectionState),
fileRenaming: get(fileRenamingState),
pathOverriding: get(pathOverridingState),
enableCustomArgs: get(enableCustomArgsState),
listView: get(listViewState),
servedFromReverseProxy: get(servedFromReverseProxyState),
appTitle: get(appTitleState)
})
}) })
)

View File

@@ -1,45 +1,34 @@
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { of } from 'fp-ts/lib/Task' import { of } from 'fp-ts/lib/Task'
import { getOrElse } from 'fp-ts/lib/TaskEither' import { getOrElse } from 'fp-ts/lib/TaskEither'
import { atom, selector } from 'recoil'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { RPCVersion } from '../types' import { RPCVersion } from '../types'
import { rpcClientState } from './rpc' import { rpcClientState } from './rpc'
import { serverURL } from './settings' import { serverURL } from './settings'
import { atom } from 'jotai'
export const connectedState = atom({ export const connectedState = atom(false)
key: 'connectedState',
default: false export const freeSpaceBytesState = atom(async (get) => {
const res = await get(rpcClientState)
.freeSpace()
.catch(() => ({ result: 0 }))
return res.result
}) })
export const freeSpaceBytesState = selector({ export const availableDownloadPathsState = atom(async (get) => {
key: 'freeSpaceBytesState', const res = await get(rpcClientState).directoryTree()
get: async ({ get }) => { .catch(() => ({ result: [] }))
const res = await get(rpcClientState).freeSpace() return res.result
.catch(() => ({ result: 0 }))
return res.result
}
}) })
export const availableDownloadPathsState = selector({ export const ytdlpRpcVersionState = atom<Promise<RPCVersion>>(async (get) => await pipe(
key: 'availableDownloadPathsState', ffetch<RPCVersion>(`${get(serverURL)}/api/v1/version`),
get: async ({ get }) => { getOrElse(() => pipe(
const res = await get(rpcClientState).directoryTree() {
.catch(() => ({ result: [] })) rpcVersion: 'unknown version',
return res.result ytdlpVersion: 'unknown version',
} },
}) of
)),
export const ytdlpRpcVersionState = selector<RPCVersion>({ )())
key: 'ytdlpRpcVersionState',
get: async ({ get }) => await pipe(
ffetch<RPCVersion>(`${get(serverURL)}/api/v1/version`),
getOrElse(() => pipe(
{
rpcVersion: 'unknown version',
ytdlpVersion: 'unknown version',
},
of
)),
)()
})

View File

@@ -1,5 +1,5 @@
import { AlertColor } from '@mui/material' import { AlertColor } from '@mui/material'
import { atom } from 'recoil' import { atom } from 'jotai'
export type Toast = { export type Toast = {
open: boolean, open: boolean,
@@ -9,7 +9,4 @@ export type Toast = {
severity?: AlertColor severity?: AlertColor
} }
export const toastListState = atom<Toast[]>({ export const toastListState = atom<Toast[]>([])
key: 'toastListState',
default: [],
})

View File

@@ -1,14 +1,10 @@
import { atom, selector } from 'recoil' import { atom } from 'jotai'
import { activeDownloadsState } from './downloads' import { activeDownloadsState } from './downloads'
export const loadingAtom = atom({ export const loadingAtom = atom(true)
key: 'loadingAtom',
default: true
})
export const totalDownloadSpeedState = selector<number>({ export const totalDownloadSpeedState = atom<number>((get) =>
key: 'totalDownloadSpeedState', get(activeDownloadsState)
get: ({ get }) => get(activeDownloadsState)
.map(d => d.progress.speed) .map(d => d.progress.speed)
.reduce((curr, next) => curr + next, 0) .reduce((curr, next) => curr + next, 0)
}) )

View File

@@ -5,12 +5,12 @@ import * as O from 'fp-ts/Option'
import { matchW } from 'fp-ts/lib/TaskEither' import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useRecoilValue } from 'recoil'
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs' import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { serverSideCookiesState, serverURL } from '../atoms/settings' import { serverSideCookiesState, serverURL } from '../atoms/settings'
import { useSubscription } from '../hooks/observable' import { useSubscription } from '../hooks/observable'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { useAtomValue } from 'jotai'
const validateCookie = (cookie: string) => pipe( const validateCookie = (cookie: string) => pipe(
cookie, cookie,
@@ -75,8 +75,8 @@ const noopValidator = (s: string): E.Either<string, string[]> => pipe(
const isCommentOrNewLine = (s: string) => s === '' || s.startsWith('\n') || s.startsWith('#') const isCommentOrNewLine = (s: string) => s === '' || s.startsWith('\n') || s.startsWith('#')
const CookiesTextField: React.FC = () => { const CookiesTextField: React.FC = () => {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useAtomValue(serverURL)
const savedCookies = useRecoilValue(serverSideCookiesState) const savedCookies = useAtomValue(serverSideCookiesState)
const { pushMessage } = useToast() const { pushMessage } = useToast()

View File

@@ -16,10 +16,10 @@ import {
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { RPCResult } from '../types' import { RPCResult } from '../types'
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils' import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
import { useAtomValue } from 'jotai'
type Props = { type Props = {
download: RPCResult download: RPCResult
@@ -37,7 +37,7 @@ const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
} }
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => { const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useAtomValue(serverURL)
const isCompleted = useCallback( const isCompleted = useCallback(
() => download.progress.percentage === '-1', () => download.progress.percentage === '-1',

View File

@@ -2,7 +2,6 @@ import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import { import {
Autocomplete, Autocomplete,
Backdrop,
Box, Box,
Button, Button,
Checkbox, Checkbox,
@@ -21,6 +20,7 @@ import Slide from '@mui/material/Slide'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { TransitionProps } from '@mui/material/transitions' import { TransitionProps } from '@mui/material/transitions'
import { useAtom, useAtomValue } from 'jotai'
import { import {
FC, FC,
Suspense, Suspense,
@@ -30,16 +30,22 @@ import {
useState, useState,
useTransition useTransition
} from 'react' } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil' import {
import { customArgsState, downloadTemplateState, filenameTemplateState, savedTemplatesState } from '../atoms/downloadTemplate' customArgsState,
downloadTemplateState,
filenameTemplateState,
savedTemplatesState
} from '../atoms/downloadTemplate'
import { settingsState } from '../atoms/settings' import { settingsState } from '../atoms/settings'
import { availableDownloadPathsState, connectedState } from '../atoms/status' import { availableDownloadPathsState, connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { toFormatArgs } from '../utils' import { toFormatArgs } from '../utils'
import ExtraDownloadOptions from './ExtraDownloadOptions' import ExtraDownloadOptions from './ExtraDownloadOptions'
import LoadingBackdrop from './LoadingBackdrop'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -57,22 +63,23 @@ type Props = {
} }
const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => { const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const settings = useRecoilValue(settingsState) const settings = useAtomValue(settingsState)
const isConnected = useRecoilValue(connectedState) const isConnected = useAtomValue(connectedState)
const availableDownloadPaths = useRecoilValue(availableDownloadPathsState) const availableDownloadPaths = useAtomValue(availableDownloadPathsState)
const downloadTemplate = useRecoilValue(downloadTemplateState) const downloadTemplate = useAtomValue(downloadTemplateState)
const savedTemplates = useRecoilValue(savedTemplatesState) const savedTemplates = useAtomValue(savedTemplatesState)
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>() const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('') const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState('') const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState('') const [pickedBestFormat, setPickedBestFormat] = useState('')
const [isFormatsLoading, setIsFormatsLoading] = useState(false)
const [customArgs, setCustomArgs] = useRecoilState(customArgsState) const [customArgs, setCustomArgs] = useAtom(customArgsState)
const [downloadPath, setDownloadPath] = useState('') const [downloadPath, setDownloadPath] = useState('')
const [filenameTemplate, setFilenameTemplate] = useRecoilState( const [filenameTemplate, setFilenameTemplate] = useAtom(
filenameTemplateState filenameTemplateState
) )
@@ -82,6 +89,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()
const { pushMessage } = useToast()
const urlInputRef = useRef<HTMLInputElement>(null) const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null) const customFilenameInputRef = useRef<HTMLInputElement>(null)
@@ -129,11 +137,28 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
setPickedVideoFormat('') setPickedVideoFormat('')
setPickedBestFormat('') setPickedBestFormat('')
if (isPlaylist) {
pushMessage('Format selection on playlist is not supported', 'warning')
resetInput()
onClose()
return
}
setIsFormatsLoading(true)
client.formats(url) client.formats(url)
?.then(formats => { ?.then(formats => {
if (formats.result._type === 'playlist') {
pushMessage('Format selection on playlist is not supported. Downloading as playlist.', 'info')
resetInput()
onClose()
return
}
setDownloadFormats(formats.result) setDownloadFormats(formats.result)
resetInput() resetInput()
}) })
.then(() => setIsFormatsLoading(false))
} }
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -175,10 +200,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
onClose={onClose} onClose={onClose}
TransitionComponent={Transition} TransitionComponent={Transition}
> >
<Backdrop <LoadingBackdrop isLoading={isPending || isFormatsLoading} />
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isPending}
/>
<AppBar sx={{ position: 'relative' }}> <AppBar sx={{ position: 'relative' }}>
<Toolbar> <Toolbar>
<IconButton <IconButton

View File

@@ -1,5 +1,5 @@
import { useAtom, useAtomValue } from 'jotai'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { loadingDownloadsState } from '../atoms/downloads' import { loadingDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings' import { listViewState } from '../atoms/settings'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
@@ -7,10 +7,10 @@ import DownloadsGridView from './DownloadsGridView'
import DownloadsTableView from './DownloadsTableView' import DownloadsTableView from './DownloadsTableView'
const Downloads: React.FC = () => { const Downloads: React.FC = () => {
const tableView = useRecoilValue(listViewState) const tableView = useAtomValue(listViewState)
const loadingDownloads = useRecoilValue(loadingDownloadsState) const loadingDownloads = useAtomValue(loadingDownloadsState)
const [isLoading, setIsLoading] = useRecoilState(loadingAtom) const [isLoading, setIsLoading] = useAtom(loadingAtom)
useEffect(() => { useEffect(() => {
if (loadingDownloads) { if (loadingDownloads) {

View File

@@ -1,5 +1,5 @@
import { Grid } from '@mui/material' import { Grid } from '@mui/material'
import { useRecoilValue } from 'recoil' import { useAtomValue } from 'jotai'
import { activeDownloadsState } from '../atoms/downloads' import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
@@ -8,7 +8,7 @@ import { ProcessStatus, RPCResult } from '../types'
import DownloadCard from './DownloadCard' import DownloadCard from './DownloadCard'
const DownloadsGridView: React.FC = () => { const DownloadsGridView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) const downloads = useAtomValue(activeDownloadsState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()

View File

@@ -20,12 +20,12 @@ import {
} from "@mui/material" } from "@mui/material"
import { forwardRef } from 'react' import { forwardRef } from 'react'
import { TableComponents, TableVirtuoso } from 'react-virtuoso' import { TableComponents, TableVirtuoso } from 'react-virtuoso'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { activeDownloadsState } from '../atoms/downloads'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types' import { ProcessStatus, RPCResult } from '../types'
import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils" import { base64URLEncode, formatSize, formatSpeedMiB } from "../utils"
import { useAtomValue } from 'jotai'
const columns = [ const columns = [
{ {
@@ -119,8 +119,8 @@ function fixedHeaderContent() {
} }
const DownloadsTableView: React.FC = () => { const DownloadsTableView: React.FC = () => {
const downloads = useRecoilValue(activeDownloadsState) const downloads = useAtomValue(activeDownloadsState)
const serverAddr = useRecoilValue(serverURL) const serverAddr = useAtomValue(serverURL)
const { client } = useRPC() const { client } = useRPC()
const viewFile = (path: string) => { const viewFile = (path: string) => {

View File

@@ -1,13 +1,13 @@
import { Autocomplete, Box, TextField, Typography } from '@mui/material' import { Autocomplete, Box, TextField, Typography } from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil'
import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate' import { customArgsState, savedTemplatesState } from '../atoms/downloadTemplate'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useAtom, useAtomValue } from 'jotai'
const ExtraDownloadOptions: React.FC = () => { const ExtraDownloadOptions: React.FC = () => {
const { i18n } = useI18n() const { i18n } = useI18n()
const customTemplates = useRecoilValue(savedTemplatesState) const customTemplates = useAtomValue(savedTemplatesState)
const [, setCustomArgs] = useRecoilState(customArgsState) const [, setCustomArgs] = useAtom(customArgsState)
return ( return (
<> <>

View File

@@ -2,7 +2,6 @@ import DownloadIcon from '@mui/icons-material/Download'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet' import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import { AppBar, CircularProgress, Divider, Toolbar } from '@mui/material' import { AppBar, CircularProgress, Divider, Toolbar } from '@mui/material'
import { Suspense } from 'react' import { Suspense } from 'react'
import { useRecoilValue } from 'recoil'
import { settingsState } from '../atoms/settings' import { settingsState } from '../atoms/settings'
import { connectedState } from '../atoms/status' import { connectedState } from '../atoms/status'
import { totalDownloadSpeedState } from '../atoms/ui' import { totalDownloadSpeedState } from '../atoms/ui'
@@ -10,11 +9,12 @@ import { useI18n } from '../hooks/useI18n'
import { formatSpeedMiB } from '../utils' import { formatSpeedMiB } from '../utils'
import FreeSpaceIndicator from './FreeSpaceIndicator' import FreeSpaceIndicator from './FreeSpaceIndicator'
import VersionIndicator from './VersionIndicator' import VersionIndicator from './VersionIndicator'
import { useAtomValue } from 'jotai'
const Footer: React.FC = () => { const Footer: React.FC = () => {
const settings = useRecoilValue(settingsState) const settings = useAtomValue(settingsState)
const isConnected = useRecoilValue(connectedState) const isConnected = useAtomValue(connectedState)
const totalDownloadSpeed = useRecoilValue(totalDownloadSpeedState) const totalDownloadSpeed = useAtomValue(totalDownloadSpeedState)
const mode = settings.theme const mode = settings.theme
const { i18n } = useI18n() const { i18n } = useI18n()

View File

@@ -1,10 +1,10 @@
import StorageIcon from '@mui/icons-material/Storage' import StorageIcon from '@mui/icons-material/Storage'
import { useRecoilValue } from 'recoil'
import { freeSpaceBytesState } from '../atoms/status' import { freeSpaceBytesState } from '../atoms/status'
import { formatSize } from '../utils' import { formatSize } from '../utils'
import { useAtomValue } from 'jotai'
const FreeSpaceIndicator = () => { const FreeSpaceIndicator = () => {
const freeSpace = useRecoilValue(freeSpaceBytesState) const freeSpace = useAtomValue(freeSpaceBytesState)
return ( return (
<div style={{ <div style={{

View File

@@ -1,5 +1,5 @@
import { useSetAtom } from 'jotai'
import { Suspense, useState } from 'react' import { Suspense, useState } from 'react'
import { useRecoilState } from 'recoil'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import DownloadDialog from './DownloadDialog' import DownloadDialog from './DownloadDialog'
@@ -7,7 +7,7 @@ import HomeSpeedDial from './HomeSpeedDial'
import TemplatesEditor from './TemplatesEditor' import TemplatesEditor from './TemplatesEditor'
const HomeActions: React.FC = () => { const HomeActions: React.FC = () => {
const [, setIsLoading] = useRecoilState(loadingAtom) const setIsLoading = useSetAtom(loadingAtom)
const [openDownload, setOpenDownload] = useState(false) const [openDownload, setOpenDownload] = useState(false)
const [openEditor, setOpenEditor] = useState(false) const [openEditor, setOpenEditor] = useState(false)

View File

@@ -9,7 +9,7 @@ import {
SpeedDialAction, SpeedDialAction,
SpeedDialIcon SpeedDialIcon
} from '@mui/material' } from '@mui/material'
import { useRecoilState, useRecoilValue } from 'recoil' import { useAtom, useAtomValue } from 'jotai'
import { listViewState, serverURL } from '../atoms/settings' import { listViewState, serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
@@ -20,8 +20,8 @@ type Props = {
} }
const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => { const HomeSpeedDial: React.FC<Props> = ({ onDownloadOpen, onEditorOpen }) => {
const serverAddr = useRecoilValue(serverURL) const serverAddr = useAtomValue(serverURL)
const [listView, setListView] = useRecoilState(listViewState) const [listView, setListView] = useAtom(listViewState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useAtomValue } from 'jotai'
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
@@ -11,7 +11,7 @@ const LogTerminal: React.FC = () => {
const boxRef = useRef<HTMLDivElement>(null) const boxRef = useRef<HTMLDivElement>(null)
const serverAddr = useRecoilValue(serverURL) const serverAddr = useAtomValue(serverURL)
const { i18n } = useI18n() const { i18n } = useI18n()

View File

@@ -1,7 +1,6 @@
import * as O from 'fp-ts/Option' import * as O from 'fp-ts/Option'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilState, useRecoilValue } from 'recoil'
import { take, timer } from 'rxjs' import { take, timer } from 'rxjs'
import { downloadsState } from '../atoms/downloads' import { downloadsState } from '../atoms/downloads'
import { rpcPollingTimeState } from '../atoms/rpc' import { rpcPollingTimeState } from '../atoms/rpc'
@@ -12,15 +11,16 @@ import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC' import { useRPC } from '../hooks/useRPC'
import { datetimeCompareFunc, isRPCResponse } from '../utils' import { datetimeCompareFunc, isRPCResponse } from '../utils'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
interface Props extends React.HTMLAttributes<HTMLBaseElement> { } interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
const SocketSubscriber: React.FC<Props> = () => { const SocketSubscriber: React.FC<Props> = () => {
const [connected, setIsConnected] = useRecoilState(connectedState) const [connected, setIsConnected] = useAtom(connectedState)
const [, setDownloads] = useRecoilState(downloadsState) const setDownloads = useSetAtom(downloadsState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState) const serverAddressAndPort = useAtomValue(serverAddressAndPortState)
const rpcPollingTime = useRecoilValue(rpcPollingTimeState) const rpcPollingTime = useAtomValue(rpcPollingTimeState)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC() const { client } = useRPC()

View File

@@ -1,8 +1,8 @@
import CloudDownloadIcon from '@mui/icons-material/CloudDownload' import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import { Container, SvgIcon, Typography, styled } from '@mui/material' import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads' import { activeDownloadsState } from '../atoms/downloads'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useAtomValue } from 'jotai'
const FlexContainer = styled(Container)({ const FlexContainer = styled(Container)({
display: 'flex', display: 'flex',
@@ -23,7 +23,7 @@ const Title = styled(Typography)({
export default function Splash() { export default function Splash() {
const { i18n } = useI18n() const { i18n } = useI18n()
const activeDownloads = useRecoilValue(activeDownloadsState) const activeDownloads = useAtomValue(activeDownloadsState)
if (activeDownloads.length !== 0) { if (activeDownloads.length !== 0) {
return null return null

View File

@@ -0,0 +1,67 @@
import { FC, useState } from 'react'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Button,
Grid,
TextField
} from '@mui/material'
import { useI18n } from '../hooks/useI18n'
import { CustomTemplate } from '../types'
interface Props {
template: CustomTemplate
onChange: (template: CustomTemplate) => void
onDelete: (id: string) => void
}
const TemplateTextField: FC<Props> = ({ template, onChange, onDelete }) => {
const { i18n } = useI18n()
const [editedTemplate, setEditedTemplate] = useState(template)
return (
<Grid
container
spacing={2}
justifyContent="center"
alignItems="center"
key={template.id}
sx={{ mt: 1 }}
>
<Grid item xs={3}>
<TextField
fullWidth
label={i18n.t('templatesEditorNameLabel')}
defaultValue={template.name}
onChange={(e) => setEditedTemplate({ ...editedTemplate, name: e.target.value })}
/>
</Grid>
<Grid item xs={9}>
<TextField
fullWidth
label={i18n.t('templatesEditorContentLabel')}
defaultValue={template.content}
onChange={(e) => setEditedTemplate({ ...editedTemplate, content: e.target.value })}
InputProps={{
endAdornment: <div style={{ display: 'flex', gap: 2 }}>
<Button
variant='contained'
onClick={() => onChange(editedTemplate)}>
<EditIcon />
</Button>
<Button
variant='contained'
onClick={() => onDelete(editedTemplate.id)}>
<DeleteIcon />
</Button>
</div>
}}
/>
</Grid>
</Grid>
)
}
export default TemplateTextField

View File

@@ -1,6 +1,7 @@
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import { import {
Alert, Alert,
AppBar, AppBar,
@@ -20,12 +21,13 @@ import { TransitionProps } from '@mui/material/transitions'
import { matchW } from 'fp-ts/lib/Either' import { matchW } from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { forwardRef, useEffect, useState, useTransition } from 'react' import { forwardRef, useEffect, useState, useTransition } from 'react'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { CustomTemplate } from '../types' import { CustomTemplate } from '../types'
import { useAtomValue } from 'jotai'
import TemplateTextField from './TemplateTextField'
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -45,7 +47,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
const [templateName, setTemplateName] = useState('') const [templateName, setTemplateName] = useState('')
const [templateContent, setTemplateContent] = useState('') const [templateContent, setTemplateContent] = useState('')
const serverAddr = useRecoilValue(serverURL) const serverAddr = useAtomValue(serverURL)
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [templates, setTemplates] = useState<CustomTemplate[]>([]) const [templates, setTemplates] = useState<CustomTemplate[]>([])
@@ -55,11 +57,11 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
useEffect(() => { useEffect(() => {
if (open) { if (open) {
getTemplates() fetchTemplates()
} }
}, [open]) }, [open])
const getTemplates = async () => { const fetchTemplates = async () => {
const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`) const task = ffetch<CustomTemplate[]>(`${serverAddr}/api/v1/template/all`)
const either = await task() const either = await task()
@@ -89,7 +91,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
(l) => pushMessage(l, 'warning'), (l) => pushMessage(l, 'warning'),
() => { () => {
pushMessage('Added template') pushMessage('Added template')
getTemplates() fetchTemplates()
setTemplateName('') setTemplateName('')
setTemplateContent('') setTemplateContent('')
} }
@@ -97,6 +99,26 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
) )
} }
const updateTemplate = async (template: CustomTemplate) => {
const task = ffetch<CustomTemplate>(`${serverAddr}/api/v1/template`, {
method: 'PATCH',
body: JSON.stringify(template)
})
const either = await task()
pipe(
either,
matchW(
(l) => pushMessage(l, 'warning'),
(r) => {
pushMessage(`Updated template ${r.name}`)
fetchTemplates()
}
)
)
}
const deleteTemplate = async (id: string) => { const deleteTemplate = async (id: string) => {
const task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, { const task = ffetch<unknown>(`${serverAddr}/api/v1/template/${id}`, {
method: 'DELETE', method: 'DELETE',
@@ -110,7 +132,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
(l) => pushMessage(l, 'warning'), (l) => pushMessage(l, 'warning'),
() => { () => {
pushMessage('Deleted template') pushMessage('Deleted template')
getTemplates() fetchTemplates()
} }
) )
) )
@@ -188,38 +210,12 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
</Grid> </Grid>
</Grid> </Grid>
{templates.map(template => ( {templates.map(template => (
<Grid <TemplateTextField
container
spacing={2}
justifyContent="center"
alignItems="center"
key={template.id} key={template.id}
sx={{ mt: 1 }} template={template}
> onChange={updateTemplate}
<Grid item xs={3}> onDelete={deleteTemplate}
<TextField />
fullWidth
label={i18n.t('templatesEditorNameLabel')}
value={template.name}
/>
</Grid>
<Grid item xs={9}>
<TextField
fullWidth
label={i18n.t('templatesEditorContentLabel')}
value={template.content}
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => {
startTransition(() => { deleteTemplate(template.id) })
}}>
<DeleteIcon />
</Button>
}}
/>
</Grid>
</Grid>
))} ))}
</Paper> </Paper>
</Grid> </Grid>

View File

@@ -2,12 +2,12 @@ import Brightness4 from '@mui/icons-material/Brightness4'
import Brightness5 from '@mui/icons-material/Brightness5' import Brightness5 from '@mui/icons-material/Brightness5'
import BrightnessAuto from '@mui/icons-material/BrightnessAuto' import BrightnessAuto from '@mui/icons-material/BrightnessAuto'
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useRecoilState } from 'recoil'
import { Theme, themeState } from '../atoms/settings' import { Theme, themeState } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useAtom } from 'jotai'
const ThemeToggler: React.FC = () => { const ThemeToggler: React.FC = () => {
const [theme, setTheme] = useRecoilState(themeState) const [theme, setTheme] = useAtom(themeState)
const actions: Record<Theme, React.ReactNode> = { const actions: Record<Theme, React.ReactNode> = {
system: <BrightnessAuto />, system: <BrightnessAuto />,

View File

@@ -0,0 +1,33 @@
import { Button, CircularProgress } from '@mui/material'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { useState } from 'react'
import { useToast } from '../hooks/toast'
const UpdateBinaryButton: React.FC = () => {
const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast()
const [isLoading, setIsLoading] = useState(false)
const updateBinary = () => {
setIsLoading(true)
client
.updateExecutable()
.then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
.then(() => setIsLoading(false))
}
return (
<Button
variant="contained"
endIcon={isLoading ? <CircularProgress size={16} color='secondary' /> : <></>}
onClick={updateBinary}
>
{i18n.t('updateBinButton')}
</Button>
)
}
export default UpdateBinaryButton

View File

@@ -1,9 +1,9 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { ytdlpRpcVersionState } from '../atoms/status' import { ytdlpRpcVersionState } from '../atoms/status'
import { useAtomValue } from 'jotai'
const VersionIndicator: React.FC = () => { const VersionIndicator: React.FC = () => {
const version = useRecoilValue(ytdlpRpcVersionState) const version = useAtomValue(ytdlpRpcVersionState)
return ( return (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>

View File

@@ -1,9 +1,9 @@
import { AlertColor } from '@mui/material' import { AlertColor } from '@mui/material'
import { useRecoilState } from 'recoil'
import { toastListState } from '../atoms/toast' import { toastListState } from '../atoms/toast'
import { useSetAtom } from 'jotai'
export const useToast = () => { export const useToast = () => {
const [, setToasts] = useRecoilState(toastListState) const setToasts = useSetAtom(toastListState)
return { return {
pushMessage: (message: string, severity?: AlertColor) => { pushMessage: (message: string, severity?: AlertColor) => {

View File

@@ -1,10 +1,11 @@
import { useRecoilValue } from 'recoil' import { useAtomValue } from 'jotai'
import { i18nBuilderState } from '../atoms/i18n' import { i18nBuilderState } from '../atoms/i18n'
export const useI18n = () => { export const useI18n = () => {
const instance = useRecoilValue(i18nBuilderState) const instance = useAtomValue(i18nBuilderState)
return { return {
i18n: instance i18n: instance,
t: instance.t
} }
} }

View File

@@ -1,8 +1,8 @@
import { useRecoilValue } from 'recoil' import { useAtomValue } from 'jotai'
import { rpcClientState } from '../atoms/rpc' import { rpcClientState } from '../atoms/rpc'
export const useRPC = () => { export const useRPC = () => {
const client = useRecoilValue(rpcClientState) const client = useAtomValue(rpcClientState)
return { return {
client client

View File

@@ -191,4 +191,11 @@ export class RPCClient {
params: [] params: []
}) })
} }
public updateExecutable() {
return this.sendHTTP({
method: 'Service.UpdateExecutable',
params: []
})
}
} }

View File

@@ -1,10 +1,10 @@
import { Alert, Snackbar } from "@mui/material" import { Alert, Snackbar } from "@mui/material"
import { useRecoilState } from 'recoil'
import { Toast, toastListState } from '../atoms/toast' import { Toast, toastListState } from '../atoms/toast'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useAtom } from 'jotai'
const Toaster: React.FC = () => { const Toaster: React.FC = () => {
const [toasts, setToasts] = useRecoilState(toastListState) const [toasts, setToasts] = useAtom(toastListState)
const deletePredicate = (t: Toast) => (Date.now() - t.createdAt) < 2000 const deletePredicate = (t: Toast) => (Date.now() - t.createdAt) < 2000

View File

@@ -69,9 +69,11 @@ export type RPCParams = {
export type DLMetadata = { export type DLMetadata = {
formats: Array<DLFormat> formats: Array<DLFormat>
_type: string
best: DLFormat best: DLFormat
thumbnail: string thumbnail: string
title: string title: string
entries: Array<DLMetadata>
} }
export type DLFormat = { export type DLFormat = {

View File

@@ -1,4 +1,6 @@
import { blue, red } from '@mui/material/colors'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { Accent } from './atoms/settings'
import type { RPCResponse } from "./types" import type { RPCResponse } from "./types"
import { ProcessStatus } from './types' import { ProcessStatus } from './types'
@@ -80,3 +82,14 @@ export const base64URLEncode = (s: string) => pipe(
btoa, btoa,
encodeURIComponent encodeURIComponent
) )
export const getAccentValue = (accent: Accent) => {
switch (accent) {
case 'default':
return blue[700]
case 'red':
return red[600]
default:
return blue[700]
}
}

View File

@@ -33,7 +33,6 @@ import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { useEffect, useMemo, useState, useTransition } from 'react' import { useEffect, useMemo, useState, useTransition } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs' import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useObservable } from '../hooks/observable' import { useObservable } from '../hooks/observable'
@@ -42,13 +41,14 @@ import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { DirectoryEntry } from '../types' import { DirectoryEntry } from '../types'
import { base64URLEncode, formatSize } from '../utils' import { base64URLEncode, formatSize } from '../utils'
import { useAtomValue } from 'jotai'
export default function Downloaded() { export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }) const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>() const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const serverAddr = useRecoilValue(serverURL) const serverAddr = useAtomValue(serverURL)
const navigate = useNavigate() const navigate = useNavigate()
const { i18n } = useI18n() const { i18n } = useI18n()

View File

@@ -1,15 +1,15 @@
import { import {
Container Container
} from '@mui/material' } from '@mui/material'
import { useRecoilValue } from 'recoil'
import { loadingAtom } from '../atoms/ui' import { loadingAtom } from '../atoms/ui'
import Downloads from '../components/Downloads' import Downloads from '../components/Downloads'
import HomeActions from '../components/HomeActions' import HomeActions from '../components/HomeActions'
import LoadingBackdrop from '../components/LoadingBackdrop' import LoadingBackdrop from '../components/LoadingBackdrop'
import Splash from '../components/Splash' import Splash from '../components/Splash'
import { useAtomValue } from 'jotai'
export default function Home() { export default function Home() {
const isLoading = useRecoilValue(loadingAtom) const isLoading = useAtomValue(loadingAtom)
return ( return (
<Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}> <Container maxWidth="xl" sx={{ mt: 2, mb: 8 }}>

View File

@@ -16,10 +16,10 @@ import { matchW } from 'fp-ts/lib/TaskEither'
import { pipe } from 'fp-ts/lib/function' import { pipe } from 'fp-ts/lib/function'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { serverURL } from '../atoms/settings' import { serverURL } from '../atoms/settings'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient' import { ffetch } from '../lib/httpClient'
import { useAtomValue } from 'jotai'
const LoginContainer = styled(Container)({ const LoginContainer = styled(Container)({
display: 'flex', display: 'flex',
@@ -43,7 +43,7 @@ export default function Login() {
const [formHasError, setFormHasError] = useState(false) const [formHasError, setFormHasError] = useState(false)
const url = useRecoilValue(serverURL) const url = useAtomValue(serverURL)
const navigate = useNavigate() const navigate = useNavigate()

View File

@@ -1,5 +1,4 @@
import { import {
Button,
Checkbox, Checkbox,
Container, Container,
FormControl, FormControl,
@@ -18,8 +17,8 @@ import {
Typography, Typography,
capitalize capitalize
} from '@mui/material' } from '@mui/material'
import { useAtom } from 'jotai'
import { Suspense, useEffect, useMemo, useState } from 'react' import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRecoilState } from 'recoil'
import { import {
Subject, Subject,
debounceTime, debounceTime,
@@ -29,8 +28,11 @@ import {
} from 'rxjs' } from 'rxjs'
import { rpcPollingTimeState } from '../atoms/rpc' import { rpcPollingTimeState } from '../atoms/rpc'
import { import {
Accent,
Language, Language,
Theme, Theme,
accentState,
accents,
appTitleState, appTitleState,
enableCustomArgsState, enableCustomArgsState,
fileRenamingState, fileRenamingState,
@@ -45,34 +47,34 @@ import {
themeState themeState
} from '../atoms/settings' } from '../atoms/settings'
import CookiesTextField from '../components/CookiesTextField' import CookiesTextField from '../components/CookiesTextField'
import UpdateBinaryButton from '../components/UpdateBinaryButton'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n' import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { validateDomain, validateIP } from '../utils' import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS // NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() { export default function Settings() {
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState) const [reverseProxy, setReverseProxy] = useAtom(servedFromReverseProxyState)
const [baseURL, setBaseURL] = useRecoilState(servedFromReverseProxySubDirState) const [baseURL, setBaseURL] = useAtom(servedFromReverseProxySubDirState)
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState) const [formatSelection, setFormatSelection] = useAtom(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState) const [pathOverriding, setPathOverriding] = useAtom(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState) const [fileRenaming, setFileRenaming] = useAtom(fileRenamingState)
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState) const [enableArgs, setEnableArgs] = useAtom(enableCustomArgsState)
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState) const [serverAddr, setServerAddr] = useAtom(serverAddressState)
const [serverPort, setServerPort] = useRecoilState(serverPortState) const [serverPort, setServerPort] = useAtom(serverPortState)
const [pollingTime, setPollingTime] = useRecoilState(rpcPollingTimeState) const [pollingTime, setPollingTime] = useAtom(rpcPollingTimeState)
const [language, setLanguage] = useRecoilState(languageState) const [language, setLanguage] = useAtom(languageState)
const [appTitle, setApptitle] = useRecoilState(appTitleState) const [appTitle, setApptitle] = useAtom(appTitleState)
const [accent, setAccent] = useAtom(accentState)
const [theme, setTheme] = useRecoilState(themeState) const [theme, setTheme] = useAtom(themeState)
const [invalidIP, setInvalidIP] = useState(false) const [invalidIP, setInvalidIP] = useState(false)
const { i18n } = useI18n() const { i18n } = useI18n()
const { client } = useRPC()
const { pushMessage } = useToast() const { pushMessage } = useToast()
@@ -140,13 +142,6 @@ export default function Settings() {
setTheme(event.target.value as Theme) setTheme(event.target.value as Theme)
} }
/**
* Updates yt-dlp binary via RPC
*/
const updateBinary = () => {
client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated'), 'success'))
}
return ( return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}> <Container maxWidth="xl" sx={{ mt: 4, mb: 8 }}>
<Paper <Paper
@@ -257,7 +252,7 @@ export default function Settings() {
Appearance Appearance
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('languageSelect')}</InputLabel> <InputLabel>{i18n.t('languageSelect')}</InputLabel>
<Select <Select
@@ -287,6 +282,22 @@ export default function Settings() {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{i18n.t('accentSelect')}</InputLabel>
<Select
defaultValue={accent}
label={i18n.t('accentSelect')}
onChange={(e) => setAccent(e.target.value as Accent)}
>
{accents.map((accent) => (
<MenuItem key={accent} value={accent}>
{capitalize(accent)}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid> </Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}> <Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
General download settings General download settings
@@ -352,14 +363,8 @@ export default function Settings() {
</Suspense> </Suspense>
</Grid> </Grid>
<Grid> <Grid>
<Stack direction="row"> <Stack direction="row" sx={{ pt: 2 }}>
<Button <UpdateBinaryButton />
sx={{ mr: 1, mt: 3 }}
variant="contained"
onClick={() => updateBinary()}
>
{i18n.t('updateBinButton')}
</Button>
</Stack> </Stack>
</Grid> </Grid>
</Paper> </Paper>

View File

@@ -1,12 +1,10 @@
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import million from 'million/compiler'
import ViteYaml from '@modyfi/vite-plugin-yaml' import ViteYaml from '@modyfi/vite-plugin-yaml'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
export default defineConfig(() => { export default defineConfig(() => {
return { return {
plugins: [ plugins: [
million.vite({ auto: true }),
react(), react(),
ViteYaml(), ViteYaml(),
], ],

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/marcopeocchi/yt-dlp-web-ui module github.com/marcopeocchi/yt-dlp-web-ui/v3
go 1.23 go 1.23

14
main.go
View File

@@ -8,10 +8,10 @@ import (
"os" "os"
"runtime" "runtime"
"github.com/marcopeocchi/yt-dlp-web-ui/server" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server"
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
) )
var ( var (
@@ -23,6 +23,7 @@ var (
downloaderPath string downloaderPath string
sessionFilePath string sessionFilePath string
localDatabasePath string localDatabasePath string
frontendPath string
requireAuth bool requireAuth bool
username string username string
@@ -52,6 +53,7 @@ func init() {
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path") flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&sessionFilePath, "session", ".", "session file path") flag.StringVar(&sessionFilePath, "session", ".", "session file path")
flag.StringVar(&localDatabasePath, "db", "local.db", "local database path") flag.StringVar(&localDatabasePath, "db", "local.db", "local database path")
flag.StringVar(&frontendPath, "web", "", "frontend web resources path")
flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file") flag.BoolVar(&enableFileLogging, "fl", false, "enable outputting logs to a file")
flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location") flag.StringVar(&logFile, "lf", "yt-dlp-webui.log", "set log file location")
@@ -69,6 +71,10 @@ func main() {
log.Fatalln(err) log.Fatalln(err)
} }
if frontendPath != "" {
frontend = os.DirFS(frontendPath)
}
c := config.Instance() c := config.Instance()
{ {

View File

@@ -1,9 +1,9 @@
{ yt-dlp-web-ui-frontend, buildGoModule, lib, makeWrapper, yt-dlp, ... }: { yt-dlp-web-ui-frontend, buildGo123Module, lib, makeWrapper, yt-dlp, ... }:
let let
fs = lib.fileset; fs = lib.fileset;
common = import ./common.nix { inherit lib; }; common = import ./common.nix { inherit lib; };
in in
buildGoModule { buildGo123Module {
pname = "yt-dlp-web-ui"; pname = "yt-dlp-web-ui";
inherit (common) version; inherit (common) version;
src = fs.toSource rec { src = fs.toSource rec {
@@ -26,7 +26,7 @@ buildGoModule {
# repo commons # repo commons
../.github ../.github
../README.md ../README.md
../LICENSE.md ../LICENSE
../.gitignore ../.gitignore
../.vscode ../.vscode
]); ]);
@@ -44,7 +44,7 @@ buildGoModule {
--prefix PATH : ${lib.makeBinPath [ yt-dlp ]} --prefix PATH : ${lib.makeBinPath [ yt-dlp ]}
''; '';
vendorHash = "sha256-guM/U9DROJMx2ctPKBQis1YRhaf6fKvvwEWgswQKMG0="; vendorHash = "sha256-c7IdCmYJEn5qJn3K8wt0qz3t0Nq9rbgWp1eONlCJOwM=";
meta = common.meta // { meta = common.meta // {
mainProgram = "yt-dlp-web-ui"; mainProgram = "yt-dlp-web-ui";

View File

@@ -9,26 +9,26 @@ import (
) )
type Config struct { type Config struct {
LogPath string `yaml:"log_path"` LogPath string `yaml:"log_path"`
EnableFileLogging bool `yaml:"enable_file_logging"` EnableFileLogging bool `yaml:"enable_file_logging"`
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
DownloadPath string `yaml:"downloadPath"` DownloadPath string `yaml:"downloadPath"`
DownloaderPath string `yaml:"downloaderPath"` DownloaderPath string `yaml:"downloaderPath"`
RequireAuth bool `yaml:"require_auth"` RequireAuth bool `yaml:"require_auth"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
QueueSize int `yaml:"queue_size"` QueueSize int `yaml:"queue_size"`
LocalDatabasePath string `yaml:"local_database_path"` LocalDatabasePath string `yaml:"local_database_path"`
SessionFilePath string `yaml:"session_file_path"` SessionFilePath string `yaml:"session_file_path"`
path string path string // private
UseOpenId bool `yaml:"use_openid"` UseOpenId bool `yaml:"use_openid"`
OpenIdProviderURL string `yaml:"openid_provider_url"` OpenIdProviderURL string `yaml:"openid_provider_url"`
OpenIdClientId string `yaml:"openid_client_id"` OpenIdClientId string `yaml:"openid_client_id"`
OpenIdClientSecret string `yaml:"openid_client_secret"` OpenIdClientSecret string `yaml:"openid_client_secret"`
OpenIdRedirectURL string `yaml:"openid_redirect_url"` OpenIdRedirectURL string `yaml:"openid_redirect_url"`
FrontendPath string `yaml:"frontend_path"`
} }
var ( var (

View File

@@ -6,7 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
) )
var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock") var lockFilePath = filepath.Join(config.Instance().Dir(), ".db.lock")

56
server/formats/parser.go Normal file
View File

@@ -0,0 +1,56 @@
package formats
import (
"encoding/json"
"log/slog"
"os/exec"
"sync"
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
)
func ParseURL(url string) (*Metadata, error) {
cmd := exec.Command(config.Instance().DownloaderPath, url, "-J")
stdout, err := cmd.Output()
if err != nil {
slog.Error("failed to retrieve metadata", slog.String("err", err.Error()))
return nil, err
}
slog.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", url),
)
info := &Metadata{URL: url}
best := &Format{}
var (
wg sync.WaitGroup
decodingError error
)
wg.Add(2)
go func() {
decodingError = json.Unmarshal(stdout, &info)
wg.Done()
}()
go func() {
decodingError = json.Unmarshal(stdout, &best)
wg.Done()
}()
wg.Wait()
if decodingError != nil {
return nil, err
}
info.Best = *best
return info, nil
}

28
server/formats/types.go Normal file
View File

@@ -0,0 +1,28 @@
package formats
// Used to deser the formats in the -J output
type Metadata struct {
Type string `json:"_type"`
Formats []Format `json:"formats"`
Best Format `json:"best"`
Thumbnail string `json:"thumbnail"`
Title string `json:"title"`
URL string `json:"url"`
Entries []Metadata `json:"entries"` // populated if url is playlist
}
func (m *Metadata) IsPlaylist() bool {
return m.Type == "playlist"
}
// A skimmed yt-dlp format node
type Format struct {
Format_id string `json:"format_id"`
Format_note string `json:"format_note"`
FPS float32 `json:"fps"`
Resolution string `json:"resolution"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Size float32 `json:"filesize_approx"`
Language string `json:"language"`
}

View File

@@ -17,8 +17,8 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
) )
/* /*

View File

@@ -7,7 +7,7 @@ import (
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
) )
const TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui" const TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"

View File

@@ -10,7 +10,6 @@ type ProgressTemplate struct {
Eta float32 `json:"eta"` Eta float32 `json:"eta"`
} }
type PostprocessTemplate struct { type PostprocessTemplate struct {
FilePath string `json:"filepath"` FilePath string `json:"filepath"`
} }
@@ -45,27 +44,6 @@ type DownloadInfo struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// Used to deser the formats in the -J output
type DownloadFormats struct {
Formats []Format `json:"formats"`
Best Format `json:"best"`
Thumbnail string `json:"thumbnail"`
Title string `json:"title"`
URL string `json:"url"`
}
// A skimmed yt-dlp format node
type Format struct {
Format_id string `json:"format_id"`
Format_note string `json:"format_note"`
FPS float32 `json:"fps"`
Resolution string `json:"resolution"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Size float32 `json:"filesize_approx"`
Language string `json:"language"`
}
// struct representing the response sent to the client // struct representing the response sent to the client
// as JSON-RPC result field // as JSON-RPC result field
type ProcessResponse struct { type ProcessResponse struct {

View File

@@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
) )
const ( const (

View File

@@ -4,8 +4,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
) )
func setupTest() { func setupTest() {

View File

@@ -7,8 +7,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
) )
type Monitor struct { type Monitor struct {

View File

@@ -8,7 +8,7 @@ import (
"sync" "sync"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
) )
// In-Memory Thread-Safe Key-Value Storage with optional persistence // In-Memory Thread-Safe Key-Value Storage with optional persistence

View File

@@ -6,7 +6,7 @@ import (
"log/slog" "log/slog"
evbus "github.com/asaskevich/EventBus" evbus "github.com/asaskevich/EventBus"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
) )

View File

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
) )
type metadata struct { type metadata struct {

View File

@@ -11,7 +11,6 @@ import (
"log/slog" "log/slog"
"regexp" "regexp"
"slices" "slices"
"sync"
"syscall" "syscall"
"os" "os"
@@ -19,7 +18,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
) )
const downloadTemplate = `download: const downloadTemplate = `download:
@@ -261,54 +260,6 @@ func (p *Process) Kill() error {
return nil return nil
} }
// Returns the available format for this URL
//
// TODO: Move out from process.go
func (p *Process) GetFormats() (DownloadFormats, error) {
cmd := exec.Command(config.Instance().DownloaderPath, p.Url, "-J")
stdout, err := cmd.Output()
if err != nil {
slog.Error("failed to retrieve metadata", slog.String("err", err.Error()))
return DownloadFormats{}, err
}
slog.Info(
"retrieving metadata",
slog.String("caller", "getFormats"),
slog.String("url", p.Url),
)
info := DownloadFormats{URL: p.Url}
best := Format{}
var (
wg sync.WaitGroup
decodingError error
)
wg.Add(2)
go func() {
decodingError = json.Unmarshal(stdout, &info)
wg.Done()
}()
go func() {
decodingError = json.Unmarshal(stdout, &best)
wg.Done()
}()
wg.Wait()
if decodingError != nil {
return DownloadFormats{}, err
}
info.Best = best
return info, nil
}
func (p *Process) GetFileName(o *DownloadOutput) error { func (p *Process) GetFileName(o *DownloadOutput) error {
cmd := exec.Command( cmd := exec.Command(
config.Instance().DownloaderPath, config.Instance().DownloaderPath,

View File

@@ -9,9 +9,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )

View File

@@ -3,7 +3,7 @@ package rest
import ( import (
"database/sql" "database/sql"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
) )
type ContainerArgs struct { type ContainerArgs struct {

View File

@@ -2,9 +2,9 @@ package rest
import ( import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
) )
func Container(args *ContainerArgs) *Handler { func Container(args *ContainerArgs) *Handler {
@@ -34,6 +34,7 @@ func ApplyRouter(args *ContainerArgs) func(chi.Router) {
r.Post("/cookies", h.SetCookies()) r.Post("/cookies", h.SetCookies())
r.Delete("/cookies", h.DeleteCookies()) r.Delete("/cookies", h.DeleteCookies())
r.Post("/template", h.AddTemplate()) r.Post("/template", h.AddTemplate())
r.Patch("/template", h.UpdateTemplate())
r.Get("/template/all", h.GetTemplates()) r.Get("/template/all", h.GetTemplates())
r.Delete("/template/{id}", h.DeleteTemplate()) r.Delete("/template/{id}", h.DeleteTemplate())
} }

View File

@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
) )
type Handler struct { type Handler struct {
@@ -34,9 +34,9 @@ func (h *Handler) Exec() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).Encode(id) if err := json.NewEncoder(w).Encode(id); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -61,6 +61,7 @@ func (h *Handler) ExecPlaylist() http.HandlerFunc {
if err := json.NewEncoder(w).Encode("ok"); err != nil { if err := json.NewEncoder(w).Encode("ok"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -75,13 +76,14 @@ func (h *Handler) ExecLivestream() http.HandlerFunc {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
h.service.ExecLivestream(req) h.service.ExecLivestream(req)
err := json.NewEncoder(w).Encode("ok") if err := json.NewEncoder(w).Encode("ok"); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -98,9 +100,9 @@ func (h *Handler) Running() http.HandlerFunc {
return return
} }
err = json.NewEncoder(w).Encode(res) if err := json.NewEncoder(w).Encode(res); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -134,21 +136,19 @@ func (h *Handler) SetCookies() http.HandlerFunc {
req := new(internal.SetCookiesRequest) req := new(internal.SetCookiesRequest)
err := json.NewDecoder(r.Body).Decode(req) if err := json.NewDecoder(r.Body).Decode(req); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
err = h.service.SetCookies(r.Context(), req.Cookies) if err := h.service.SetCookies(r.Context(), req.Cookies); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
err = json.NewEncoder(w).Encode("ok") if err := json.NewEncoder(w).Encode("ok"); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -157,15 +157,14 @@ func (h *Handler) DeleteCookies() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
err := h.service.SetCookies(r.Context(), "") if err := h.service.SetCookies(r.Context(), ""); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
err = json.NewEncoder(w).Encode("ok") if err := json.NewEncoder(w).Encode("ok"); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -178,8 +177,7 @@ func (h *Handler) AddTemplate() http.HandlerFunc {
req := new(internal.CustomTemplate) req := new(internal.CustomTemplate)
err := json.NewDecoder(r.Body).Decode(req) if err := json.NewDecoder(r.Body).Decode(req); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -189,15 +187,14 @@ func (h *Handler) AddTemplate() http.HandlerFunc {
return return
} }
err = h.service.SaveTemplate(r.Context(), req) if err := h.service.SaveTemplate(r.Context(), req); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
err = json.NewEncoder(w).Encode("ok") if err := json.NewEncoder(w).Encode("ok"); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -221,6 +218,33 @@ func (h *Handler) GetTemplates() http.HandlerFunc {
} }
} }
func (h *Handler) UpdateTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
req := &internal.CustomTemplate{}
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
res, err := h.service.UpdateTemplate(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func (h *Handler) DeleteTemplate() http.HandlerFunc { func (h *Handler) DeleteTemplate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
@@ -229,15 +253,14 @@ func (h *Handler) DeleteTemplate() http.HandlerFunc {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
err := h.service.DeleteTemplate(r.Context(), id) if err := h.service.DeleteTemplate(r.Context(), id); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
err = json.NewEncoder(w).Encode("ok") if err := json.NewEncoder(w).Encode("ok"); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }
@@ -266,6 +289,7 @@ func (h *Handler) GetVersion() http.HandlerFunc {
if err := json.NewEncoder(w).Encode(res); err != nil { if err := json.NewEncoder(w).Encode(res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
} }
} }

View File

@@ -10,9 +10,9 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal/livestream"
) )
type Service struct { type Service struct {
@@ -133,6 +133,22 @@ func (s *Service) GetTemplates(ctx context.Context) (*[]internal.CustomTemplate,
return &templates, nil return &templates, nil
} }
func (s *Service) UpdateTemplate(ctx context.Context, t *internal.CustomTemplate) (*internal.CustomTemplate, error) {
conn, err := s.db.Conn(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
_, err = conn.ExecContext(ctx, "UPDATE templates SET name = ?, content = ? WHERE id = ?", t.Name, t.Content, t.Id)
if err != nil {
return nil, err
}
return t, nil
}
func (s *Service) DeleteTemplate(ctx context.Context, id string) error { func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
conn, err := s.db.Conn(ctx) conn, err := s.db.Conn(ctx)
if err != nil { if err != nil {
@@ -148,7 +164,7 @@ func (s *Service) DeleteTemplate(ctx context.Context, id string) error {
func (s *Service) GetVersion(ctx context.Context) (string, string, error) { func (s *Service) GetVersion(ctx context.Context) (string, string, error) {
//TODO: load from realease properties file, or anything else outside code //TODO: load from realease properties file, or anything else outside code
const CURRENT_RPC_VERSION = "3.2.1" const CURRENT_RPC_VERSION = "3.2.3"
result := make(chan string, 1) result := make(chan string, 1)

View File

@@ -2,11 +2,11 @@ package rpc
import ( import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal/livestream"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
) )
// Dependency injection container. // Dependency injection container.

View File

@@ -4,10 +4,11 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/formats"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/sys" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/updater" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/sys"
"github.com/marcopeocchi/yt-dlp-web-ui/v3/server/updater"
) )
type Service struct { type Service struct {
@@ -21,12 +22,6 @@ type Pending []string
type NoArgs struct{} type NoArgs struct{}
type Args struct {
Id string
URL string
Params []string
}
// Exec spawns a Process. // Exec spawns a Process.
// The result of the execution is the newly spawned process Id. // The result of the execution is the newly spawned process Id.
func (s *Service) Exec(args internal.DownloadRequest, result *string) error { func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
@@ -91,7 +86,7 @@ func (s *Service) KillAllLivestream(args NoArgs, result *struct{}) error {
} }
// Progess retrieves the Progress of a specific Process given its Id // Progess retrieves the Progress of a specific Process given its Id
func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error { func (s *Service) Progess(args internal.DownloadRequest, progress *internal.DownloadProgress) error {
proc, err := s.db.Get(args.Id) proc, err := s.db.Get(args.Id)
if err != nil { if err != nil {
return err return err
@@ -102,13 +97,20 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
} }
// Progess retrieves available format for a given resource // Progess retrieves available format for a given resource
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error { func (s *Service) Formats(args internal.DownloadRequest, meta *formats.Metadata) error {
var ( var err error
err error
p = internal.Process{Url: args.URL} metadata, err := formats.ParseURL(args.URL)
) if err != nil && metadata == nil {
*meta, err = p.GetFormats() return err
return err }
if metadata.IsPlaylist() {
go internal.PlaylistDetect(args, s.mq, s.db)
}
*meta = *metadata
return nil
} }
// Pending retrieves a slice of all Pending/Running processes ids // Pending retrieves a slice of all Pending/Running processes ids

View File

@@ -19,16 +19,16 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/dbutil" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/dbutil"
"github.com/marcopeocchi/yt-dlp-web-ui/server/handlers" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal/livestream" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal/livestream"
"github.com/marcopeocchi/yt-dlp-web-ui/server/logging" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/logging"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" middlewares "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/openid" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/openid"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/rest"
ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/server/rpc" ytdlpRPC "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/rpc"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )

View File

@@ -4,8 +4,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/internal"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )

View File

@@ -3,7 +3,7 @@ package updater
import ( import (
"os/exec" "os/exec"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/v3/server/config"
) )
// Update using the builtin function of yt-dlp // Update using the builtin function of yt-dlp