diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx
index 55b1ffe..bc23444 100644
--- a/frontend/src/Layout.tsx
+++ b/frontend/src/Layout.tsx
@@ -1,14 +1,11 @@
import { ThemeProvider } from '@emotion/react'
-
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard'
import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import Storage from '@mui/icons-material/Storage'
-
import { Box, createTheme } from '@mui/material'
-
import DownloadIcon from '@mui/icons-material/Download'
import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider'
@@ -19,20 +16,16 @@ import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
-
import { grey } from '@mui/material/colors'
-
import { useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
-
import { Link, Outlet } from 'react-router-dom'
import { RootState } from './stores/store'
-
import AppBar from './components/AppBar'
import Drawer from './components/Drawer'
-
import Logout from './components/Logout'
import ThemeToggler from './components/ThemeToggler'
+import Toaster from './providers/ToasterProvider'
import I18nProvider from './providers/i18nProvider'
import RPCClientProvider from './providers/rpcClientProvider'
import { formatGiB } from './utils'
@@ -184,6 +177,7 @@ export default function Layout() {
+
diff --git a/frontend/src/components/DownloadsCardView.tsx b/frontend/src/components/DownloadsCardView.tsx
index 7ac9eca..741a09d 100644
--- a/frontend/src/components/DownloadsCardView.tsx
+++ b/frontend/src/components/DownloadsCardView.tsx
@@ -1,8 +1,9 @@
-import { Grid, Snackbar } from "@mui/material"
-import { Fragment, useContext, useEffect, useState } from "react"
+import { Grid } from "@mui/material"
+import { Fragment, useContext } from "react"
+import { useToast } from "../hooks/toast"
+import { I18nContext } from "../providers/i18nProvider"
import type { RPCResult } from "../types"
import { StackableResult } from "./StackableResult"
-import { I18nContext } from "../providers/i18nProvider"
type Props = {
downloads: RPCResult[]
@@ -10,9 +11,8 @@ type Props = {
}
export function DownloadsCardView({ downloads, onStop }: Props) {
- const [openSB, setOpenSB] = useState(false)
-
const { i18n } = useContext(I18nContext)
+ const { pushMessage } = useToast()
return (
@@ -26,7 +26,7 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
thumbnail={download.info.thumbnail}
percentage={download.progress.percentage}
onStop={() => onStop(download.id)}
- onCopy={() => setOpenSB(true)}
+ onCopy={() => pushMessage(i18n.t('clipboardAction'))}
resolution={download.info.resolution ?? ''}
speed={download.progress.speed}
size={download.info.filesize_approx ?? 0}
@@ -36,12 +36,6 @@ export function DownloadsCardView({ downloads, onStop }: Props) {
))
}
- setOpenSB(false)}
- message={i18n.t('clipboardAction')}
- />
)
}
\ No newline at end of file
diff --git a/frontend/src/components/DownloadsListView.tsx b/frontend/src/components/DownloadsListView.tsx
index 3f4a26d..9994992 100644
--- a/frontend/src/components/DownloadsListView.tsx
+++ b/frontend/src/components/DownloadsListView.tsx
@@ -19,13 +19,13 @@ type Props = {
onStop: (id: string) => void
}
-export function DownloadsListView({ downloads, onStop }: Props) {
+export const DownloadsListView: React.FC = ({ downloads, onStop }) => {
return (
-
+
-
+
Title
@@ -52,8 +52,9 @@ export function DownloadsListView({ downloads, onStop }: Props) {
) => {
+ state.message = action.payload.message
+ state.severity = action.payload.severity
+ state.open = true
+ },
+ setOpen: (state) => {
+ state.open = true
+ },
+ setClose: (state) => {
+ state.open = false
+ },
+ }
+})
+
+export const {
+ setMessage,
+ setClose,
+ setOpen,
+} = toastSlice.actions
+
+export default toastSlice.reducer
\ No newline at end of file
diff --git a/frontend/src/hooks/toast.ts b/frontend/src/hooks/toast.ts
new file mode 100644
index 0000000..32f1f93
--- /dev/null
+++ b/frontend/src/hooks/toast.ts
@@ -0,0 +1,16 @@
+import { useDispatch } from "react-redux"
+import { setMessage } from "../features/ui/toastSlice"
+import { AlertColor } from "@mui/material"
+
+export const useToast = () => {
+ const dispatch = useDispatch()
+
+ return {
+ pushMessage: (message: string, severity?: AlertColor) => {
+ dispatch(setMessage({
+ message: message,
+ severity: severity
+ }))
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/providers/ToasterProvider.tsx b/frontend/src/providers/ToasterProvider.tsx
new file mode 100644
index 0000000..dc7e5f8
--- /dev/null
+++ b/frontend/src/providers/ToasterProvider.tsx
@@ -0,0 +1,23 @@
+import { Alert, Snackbar } from "@mui/material"
+import { useDispatch, useSelector } from "react-redux"
+import { setClose } from "../features/ui/toastSlice"
+import { RootState } from "../stores/store"
+
+const Toaster: React.FC = () => {
+ const toast = useSelector((state: RootState) => state.toast)
+ const dispatch = useDispatch()
+
+ return (
+ dispatch(setClose())}
+ >
+
+ {toast.message}
+
+
+ )
+}
+
+export default Toaster
\ No newline at end of file
diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts
index 33ff206..b4bf102 100644
--- a/frontend/src/stores/store.ts
+++ b/frontend/src/stores/store.ts
@@ -1,12 +1,14 @@
import { configureStore } from '@reduxjs/toolkit'
import settingsReducer from '../features/settings/settingsSlice'
import statussReducer from '../features/status/statusSlice'
+import toastReducer from '../features/ui/toastSlice'
export const store = configureStore({
- reducer: {
- settings: settingsReducer,
- status: statussReducer,
- },
+ reducer: {
+ settings: settingsReducer,
+ status: statussReducer,
+ toast: toastReducer,
+ },
})
export type RootState = ReturnType
diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx
index 7b99fd9..b3e517b 100644
--- a/frontend/src/views/Home.tsx
+++ b/frontend/src/views/Home.tsx
@@ -2,11 +2,9 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import {
- Alert,
Backdrop,
CircularProgress,
Container,
- Snackbar,
SpeedDial,
SpeedDialAction,
SpeedDialIcon
@@ -19,6 +17,7 @@ import { DownloadsListView } from '../components/DownloadsListView'
import Splash from '../components/Splash'
import { toggleListView } from '../features/settings/settingsSlice'
import { connected, setFreeSpace } from '../features/status/statusSlice'
+import { useToast } from '../hooks/toast'
import { socket$ } from '../lib/rpcClient'
import { I18nContext } from '../providers/i18nProvider'
import { RPCClientContext } from '../providers/rpcClientProvider'
@@ -27,24 +26,19 @@ import type { RPCResponse, RPCResult } from '../types'
import { datetimeCompareFunc, isRPCResponse } from '../utils'
export default function Home() {
- // redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
- // ephemeral state
const [activeDownloads, setActiveDownloads] = useState()
-
const [showBackdrop, setShowBackdrop] = useState(true)
- const [showToast, setShowToast] = useState(true)
-
const [openDialog, setOpenDialog] = useState(false)
- const [socketHasError, setSocketHasError] = useState(false)
- // context
const { i18n } = useContext(I18nContext)
const { client } = useContext(RPCClientContext)
+ const { pushMessage } = useToast()
+
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
@@ -56,13 +50,12 @@ export default function Home() {
dispatch(connected())
},
error: () => {
- setSocketHasError(true)
+ pushMessage(
+ `${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
+ "error"
+ )
setShowBackdrop(false)
- },
- complete: () => {
- setSocketHasError(true)
- setShowBackdrop(false)
- },
+ }
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
@@ -80,7 +73,10 @@ export default function Home() {
.freeSpace()
.then(bytes => dispatch(setFreeSpace(bytes.result)))
.catch(() => {
- setSocketHasError(true)
+ pushMessage(
+ `${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
+ "error"
+ )
setShowBackdrop(false)
})
}, [])
@@ -98,6 +94,12 @@ export default function Home() {
a.info.created_at,
)))
})
+
+ pushMessage(
+ `Connected to (${settings.serverAddr}:${settings.serverPort})`,
+ "success"
+ )
+
return () => sub.unsubscribe()
}, [socket$, status.connected])
@@ -107,11 +109,6 @@ export default function Home() {
}
}, [activeDownloads?.length])
- /**
- * Abort a specific download if id's provided, other wise abort all running ones.
- * @param id The download id / pid
- * @returns void
- */
const abort = (id?: string) => {
if (id) {
client.kill(id)
@@ -136,20 +133,6 @@ export default function Home() {
:
}
- setShowToast(false)}
- >
-
- {`Connected to (${settings.serverAddr}:${settings.serverPort})`}
-
-
-
-
- {`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
-
-
state.status)
const settings = useSelector((state: RootState) => state.settings)
const [invalidIP, setInvalidIP] = useState(false);
@@ -57,6 +55,8 @@ export default function Settings() {
const { i18n } = useContext(I18nContext)
const { client } = useContext(RPCClientContext)
+ const { pushMessage } = useToast()
+
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
const serverAddr$ = useMemo(() => new Subject(), [])
@@ -110,10 +110,10 @@ export default function Settings() {
}
/**
- * Send via WebSocket a message to update yt-dlp binary
+ * Updates yt-dlp binary via RPC
*/
const updateBinary = () => {
- client.updateExecutable().then(() => dispatch(updated()))
+ client.updateExecutable().then(() => pushMessage(i18n.t('toastUpdated')))
}
return (
@@ -270,7 +270,7 @@ export default function Settings() {
@@ -280,12 +280,6 @@ export default function Settings() {
-
);
}
diff --git a/server/cli/ascii.go b/server/cli/ascii.go
index 509f1f4..7df34a6 100644
--- a/server/cli/ascii.go
+++ b/server/cli/ascii.go
@@ -1,7 +1,5 @@
package cli
-import "fmt"
-
const (
// FG
Red = "\033[31m"
@@ -12,12 +10,8 @@ const (
Cyan = "\033[36m"
Reset = "\033[0m"
// BG
- BgRed = "\033[1;41m"
- BgBlue = "\033[1;44m"
- BgGreen = "\033[1;42m"
+ BgRed = "\033[1;41m"
+ BgBlue = "\033[1;44m"
+ BgGreen = "\033[1;42m"
+ BgMagenta = "\033[1;45m"
)
-
-// Formats a message with the specified ascii escape code, then reset.
-func Format(message string, code string) string {
- return fmt.Sprintf("%s%s%s", code, message, Reset)
-}
diff --git a/server/handlers/archive.go b/server/handlers/archive.go
new file mode 100644
index 0000000..bc68895
--- /dev/null
+++ b/server/handlers/archive.go
@@ -0,0 +1,157 @@
+package handlers
+
+import (
+ "encoding/hex"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/goccy/go-json"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
+)
+
+const (
+ TOKEN_COOKIE_NAME = "jwt"
+)
+
+type DirectoryEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Size int64 `json:"size"`
+ SHASum string `json:"shaSum"`
+ ModTime time.Time `json:"modTime"`
+ IsVideo bool `json:"isVideo"`
+ IsDirectory bool `json:"isDirectory"`
+}
+
+func walkDir(root string) (*[]DirectoryEntry, error) {
+ files := []DirectoryEntry{}
+
+ dirs, err := os.ReadDir(root)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, d := range dirs {
+ if !utils.IsValidEntry(d) {
+ continue
+ }
+
+ path := filepath.Join(root, d.Name())
+
+ info, err := d.Info()
+ if err != nil {
+ return nil, err
+ }
+
+ files = append(files, DirectoryEntry{
+ Path: path,
+ Name: d.Name(),
+ Size: info.Size(),
+ SHASum: utils.ShaSumString(path),
+ IsVideo: utils.IsVideo(d),
+ IsDirectory: d.IsDir(),
+ ModTime: info.ModTime(),
+ })
+ }
+
+ return &files, err
+}
+
+type ListRequest struct {
+ SubDir string `json:"subdir"`
+ OrderBy string `json:"orderBy"`
+}
+
+func ListDownloaded(w http.ResponseWriter, r *http.Request) {
+ root := config.Instance().GetConfig().DownloadPath
+ req := new(ListRequest)
+
+ err := json.NewDecoder(r.Body).Decode(&req)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ files, err := walkDir(filepath.Join(root, req.SubDir))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if req.OrderBy == "modtime" {
+ sort.SliceStable(*files, func(i, j int) bool {
+ return (*files)[i].ModTime.After((*files)[j].ModTime)
+ })
+ }
+
+ w.WriteHeader(http.StatusOK)
+ err = json.NewEncoder(w).Encode(files)
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+type DeleteRequest = DirectoryEntry
+
+func DeleteFile(w http.ResponseWriter, r *http.Request) {
+ req := new(DeleteRequest)
+
+ err := json.NewDecoder(r.Body).Decode(&req)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ sum := utils.ShaSumString(req.Path)
+ if sum != req.SHASum {
+ http.Error(w, "shasum mismatch", http.StatusBadRequest)
+ return
+ }
+
+ err = os.Remove(req.Path)
+ if err != nil {
+ http.Error(w, "shasum mismatch", http.StatusBadRequest)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode("ok")
+}
+
+func SendFile(w http.ResponseWriter, r *http.Request) {
+ path := chi.URLParam(r, "id")
+
+ if path == "" {
+ http.Error(w, "inexistent path", http.StatusBadRequest)
+ return
+ }
+
+ decoded, err := hex.DecodeString(path)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ decodedStr := string(decoded)
+
+ root := config.Instance().GetConfig().DownloadPath
+
+ // TODO: further path / file validations
+ if strings.Contains(filepath.Dir(decodedStr), root) {
+ // ctx.Response().Header.Set(
+ // "Content-Disposition",
+ // "inline; filename="+filepath.Base(decodedStr),
+ // )
+
+ http.ServeFile(w, r, decodedStr)
+ }
+
+ w.WriteHeader(http.StatusUnauthorized)
+}
diff --git a/server/handlers/login.go b/server/handlers/login.go
new file mode 100644
index 0000000..e8c8233
--- /dev/null
+++ b/server/handlers/login.go
@@ -0,0 +1,65 @@
+package handlers
+
+import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/goccy/go-json"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
+)
+
+type LoginRequest struct {
+ Secret string `json:"secret"`
+}
+
+func Login(w http.ResponseWriter, r *http.Request) {
+ req := new(LoginRequest)
+ err := json.NewDecoder(r.Body).Decode(&req)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if config.Instance().GetConfig().RPCSecret != req.Secret {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ expiresAt := time.Now().Add(time.Hour * 24 * 30)
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+ "expiresAt": expiresAt,
+ })
+
+ tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ cookie := &http.Cookie{
+ Name: TOKEN_COOKIE_NAME,
+ HttpOnly: true,
+ Secure: false,
+ Expires: expiresAt, // 30 days
+ Value: tokenString,
+ Path: "/",
+ }
+
+ http.SetCookie(w, cookie)
+}
+
+func Logout(w http.ResponseWriter, r *http.Request) {
+ cookie := &http.Cookie{
+ Name: TOKEN_COOKIE_NAME,
+ HttpOnly: true,
+ Secure: false,
+ Expires: time.Now(),
+ Value: "",
+ Path: "/",
+ }
+
+ http.SetCookie(w, cookie)
+}
diff --git a/server/internal/common.go b/server/internal/common.go
index bf1a3fc..06c8049 100644
--- a/server/internal/common.go
+++ b/server/internal/common.go
@@ -66,8 +66,8 @@ type AbortRequest struct {
// struct representing the intent to start a download
type DownloadRequest struct {
Id string
- URL string
- Path string
- Rename string
- Params []string
+ URL string `json:"url"`
+ Path string `json:"path"`
+ Rename string `json:"rename"`
+ Params []string `json:"params"`
}
diff --git a/server/internal/memory_db.go b/server/internal/memory_db.go
index 5aaded3..5c90b63 100644
--- a/server/internal/memory_db.go
+++ b/server/internal/memory_db.go
@@ -125,12 +125,18 @@ func (m *MemoryDB) Restore() {
}
for _, proc := range session.Processes {
- m.table.Store(proc.Id, &Process{
+ restored := &Process{
Id: proc.Id,
Url: proc.Info.URL,
Info: proc.Info,
Progress: proc.Progress,
- })
+ }
+
+ m.table.Store(proc.Id, restored)
+
+ if restored.Progress.Percentage != "-1" {
+ go restored.Start()
+ }
}
log.Println(cli.BgGreen, "Successfully restored session", cli.Reset)
diff --git a/server/internal/process.go b/server/internal/process.go
index f549b60..1be9019 100644
--- a/server/internal/process.go
+++ b/server/internal/process.go
@@ -16,6 +16,7 @@ import (
"time"
"github.com/marcopeocchi/fazzoletti/slices"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
@@ -136,8 +137,11 @@ func (p *Process) Start() {
Speed: stdout.Speed,
ETA: stdout.Eta,
}
- shortId := strings.Split(p.Id, "-")[0]
- log.Printf("[%s] %s %s\n", shortId, p.Url, p.Progress.Percentage)
+ log.Println(
+ cli.BgGreen, "DL", cli.Reset,
+ cli.BgBlue, p.getShortId(), cli.Reset,
+ p.Url, stdout.Percentage,
+ )
}
}
}()
@@ -156,6 +160,14 @@ func (p *Process) Complete() {
Speed: 0,
ETA: 0,
}
+
+ shortId := p.getShortId()
+
+ log.Println(
+ cli.BgMagenta, "FINISH", cli.Reset,
+ cli.BgBlue, shortId, cli.Reset,
+ p.Url,
+ )
}
// Kill a process and remove it from the memory
@@ -202,6 +214,12 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
return DownloadFormats{}, err
}
+ log.Println(
+ cli.BgRed, "Metadata", cli.Reset,
+ cli.BgBlue, p.getShortId(), cli.Reset,
+ p.Url,
+ )
+
go func() {
decodingError = json.NewDecoder(stdout).Decode(&info)
wg.Done()
@@ -248,6 +266,12 @@ func (p *Process) SetMetadata() error {
return err
}
+ log.Println(
+ cli.BgRed, "Metadata", cli.Reset,
+ cli.BgBlue, p.getShortId(), cli.Reset,
+ p.Url,
+ )
+
err = json.NewDecoder(stdout).Decode(&info)
if err != nil {
return err
@@ -260,3 +284,7 @@ func (p *Process) SetMetadata() error {
return err
}
+
+func (p *Process) getShortId() string {
+ return strings.Split(p.Id, "-")[0]
+}
diff --git a/server/rest/container.go b/server/rest/container.go
new file mode 100644
index 0000000..6067c2c
--- /dev/null
+++ b/server/rest/container.go
@@ -0,0 +1,25 @@
+package rest
+
+import (
+ "github.com/go-chi/chi/v5"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
+ middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
+)
+
+func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Handler {
+ var (
+ service = ProvideService(db, mq)
+ handler = ProvideHandler(service)
+ )
+ return handler
+}
+
+func ApplyRouter(db *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Router) {
+ h := Container(db, mq)
+
+ return func(r chi.Router) {
+ r.Use(middlewares.Authenticated)
+ r.Post("/exec", h.Exec())
+ r.Get("/running", h.Running())
+ }
+}
diff --git a/server/rest/handlers.go b/server/rest/handlers.go
index 94380d2..5b8dfa3 100644
--- a/server/rest/handlers.go
+++ b/server/rest/handlers.go
@@ -1,212 +1,56 @@
package rest
import (
- "encoding/hex"
"net/http"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
- "github.com/go-chi/chi/v5"
"github.com/goccy/go-json"
- "github.com/golang-jwt/jwt/v5"
- "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
- "github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
)
-const (
- TOKEN_COOKIE_NAME = "jwt"
-)
-
-type DirectoryEntry struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Size int64 `json:"size"`
- SHASum string `json:"shaSum"`
- ModTime time.Time `json:"modTime"`
- IsVideo bool `json:"isVideo"`
- IsDirectory bool `json:"isDirectory"`
+type Handler struct {
+ service *Service
}
-func walkDir(root string) (*[]DirectoryEntry, error) {
- files := []DirectoryEntry{}
+func (h *Handler) Exec() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ defer r.Body.Close()
- dirs, err := os.ReadDir(root)
- if err != nil {
- return nil, err
- }
+ w.Header().Set("Content-Type", "application/json")
- for _, d := range dirs {
- if !utils.IsValidEntry(d) {
- continue
+ req := internal.DownloadRequest{}
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
}
- path := filepath.Join(root, d.Name())
-
- info, err := d.Info()
+ id, err := h.service.Exec(req)
if err != nil {
- return nil, err
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
}
- files = append(files, DirectoryEntry{
- Path: path,
- Name: d.Name(),
- Size: info.Size(),
- SHASum: utils.ShaSumString(path),
- IsVideo: utils.IsVideo(d),
- IsDirectory: d.IsDir(),
- ModTime: info.ModTime(),
- })
- }
-
- return &files, err
-}
-
-type ListRequest struct {
- SubDir string `json:"subdir"`
- OrderBy string `json:"orderBy"`
-}
-
-func ListDownloaded(w http.ResponseWriter, r *http.Request) {
- root := config.Instance().GetConfig().DownloadPath
- req := new(ListRequest)
-
- err := json.NewDecoder(r.Body).Decode(&req)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- files, err := walkDir(filepath.Join(root, req.SubDir))
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- if req.OrderBy == "modtime" {
- sort.SliceStable(*files, func(i, j int) bool {
- return (*files)[i].ModTime.After((*files)[j].ModTime)
- })
- }
-
- w.WriteHeader(http.StatusOK)
- err = json.NewEncoder(w).Encode(files)
-
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ err = json.NewEncoder(w).Encode(id)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
}
}
-type DeleteRequest = DirectoryEntry
+func (h *Handler) Running() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ defer r.Body.Close()
-func DeleteFile(w http.ResponseWriter, r *http.Request) {
- req := new(DeleteRequest)
+ w.Header().Set("Content-Type", "application/json")
- err := json.NewDecoder(r.Body).Decode(&req)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
+ res, err := h.service.Running(r.Context())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ err = json.NewEncoder(w).Encode(res)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
}
-
- sum := utils.ShaSumString(req.Path)
- if sum != req.SHASum {
- http.Error(w, "shasum mismatch", http.StatusBadRequest)
- return
- }
-
- err = os.Remove(req.Path)
- if err != nil {
- http.Error(w, "shasum mismatch", http.StatusBadRequest)
- return
- }
-
- w.WriteHeader(http.StatusOK)
- json.NewEncoder(w).Encode("ok")
-}
-
-func SendFile(w http.ResponseWriter, r *http.Request) {
- path := chi.URLParam(r, "id")
-
- if path == "" {
- http.Error(w, "inexistent path", http.StatusBadRequest)
- return
- }
-
- decoded, err := hex.DecodeString(path)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- decodedStr := string(decoded)
-
- root := config.Instance().GetConfig().DownloadPath
-
- // TODO: further path / file validations
- if strings.Contains(filepath.Dir(decodedStr), root) {
- // ctx.Response().Header.Set(
- // "Content-Disposition",
- // "inline; filename="+filepath.Base(decodedStr),
- // )
-
- http.ServeFile(w, r, decodedStr)
- }
-
- w.WriteHeader(http.StatusUnauthorized)
-}
-
-type LoginRequest struct {
- Secret string `json:"secret"`
-}
-
-func Login(w http.ResponseWriter, r *http.Request) {
- req := new(LoginRequest)
- err := json.NewDecoder(r.Body).Decode(&req)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if config.Instance().GetConfig().RPCSecret != req.Secret {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- expiresAt := time.Now().Add(time.Hour * 24 * 30)
-
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
- "expiresAt": expiresAt,
- })
-
- tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- cookie := &http.Cookie{
- Name: TOKEN_COOKIE_NAME,
- HttpOnly: true,
- Secure: false,
- Expires: expiresAt, // 30 days
- Value: tokenString,
- Path: "/",
- }
-
- http.SetCookie(w, cookie)
-}
-
-func Logout(w http.ResponseWriter, r *http.Request) {
- cookie := &http.Cookie{
- Name: TOKEN_COOKIE_NAME,
- HttpOnly: true,
- Secure: false,
- Expires: time.Now(),
- Value: "",
- Path: "/",
- }
-
- http.SetCookie(w, cookie)
}
diff --git a/server/rest/provider.go b/server/rest/provider.go
new file mode 100644
index 0000000..37556aa
--- /dev/null
+++ b/server/rest/provider.go
@@ -0,0 +1,34 @@
+package rest
+
+import (
+ "sync"
+
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
+)
+
+var (
+ service *Service
+ handler *Handler
+
+ serviceOnce sync.Once
+ handlerOnce sync.Once
+)
+
+func ProvideService(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
+ serviceOnce.Do(func() {
+ service = &Service{
+ db: db,
+ mq: mq,
+ }
+ })
+ return service
+}
+
+func ProvideHandler(svc *Service) *Handler {
+ handlerOnce.Do(func() {
+ handler = &Handler{
+ service: svc,
+ }
+ })
+ return handler
+}
diff --git a/server/rest/service.go b/server/rest/service.go
new file mode 100644
index 0000000..446268e
--- /dev/null
+++ b/server/rest/service.go
@@ -0,0 +1,38 @@
+package rest
+
+import (
+ "context"
+ "errors"
+
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
+)
+
+type Service struct {
+ db *internal.MemoryDB
+ mq *internal.MessageQueue
+}
+
+func (s *Service) Exec(req internal.DownloadRequest) (string, error) {
+ p := &internal.Process{
+ Url: req.URL,
+ Params: req.Params,
+ Output: internal.DownloadOutput{
+ Path: req.Path,
+ Filename: req.Rename,
+ },
+ }
+
+ id := s.db.Set(p)
+ s.mq.Publish(p)
+
+ return id, nil
+}
+
+func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, error) {
+ select {
+ case <-ctx.Done():
+ return nil, errors.New("context cancelled")
+ default:
+ return s.db.All(), nil
+ }
+}
diff --git a/server/rpc/container.go b/server/rpc/container.go
new file mode 100644
index 0000000..c3a5f95
--- /dev/null
+++ b/server/rpc/container.go
@@ -0,0 +1,24 @@
+package rpc
+
+import (
+ "github.com/go-chi/chi/v5"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
+ middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
+)
+
+// Dependency injection container.
+func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
+ return &Service{
+ db: db,
+ mq: mq,
+ }
+}
+
+// RPC service must be registered before applying this router!
+func ApplyRouter() func(chi.Router) {
+ return func(r chi.Router) {
+ r.Use(middlewares.Authenticated)
+ r.Get("/ws", WebSocket)
+ r.Post("/http", Post)
+ }
+}
diff --git a/server/rpc/service.go b/server/rpc/service.go
index b7c0382..6bb5b23 100644
--- a/server/rpc/service.go
+++ b/server/rpc/service.go
@@ -24,14 +24,6 @@ type Args struct {
Params []string
}
-// Dependency injection container.
-func Container(db *internal.MemoryDB, mq *internal.MessageQueue) *Service {
- return &Service{
- db: db,
- mq: mq,
- }
-}
-
// Exec spawns a Process.
// The result of the execution is the newly spawned process Id.
func (s *Service) Exec(args internal.DownloadRequest, result *string) error {
diff --git a/server/server.go b/server/server.go
index 98ad676..75fa9db 100644
--- a/server/server.go
+++ b/server/server.go
@@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
+ "github.com/marcopeocchi/yt-dlp-web-ui/server/handlers"
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
@@ -68,23 +69,22 @@ func newServer(c serverConfig) *http.Server {
// Archive routes
r.Route("/archive", func(r chi.Router) {
r.Use(middlewares.Authenticated)
- r.Post("/downloaded", rest.ListDownloaded)
- r.Post("/delete", rest.DeleteFile)
- r.Get("/d/{id}", rest.SendFile)
+ r.Post("/downloaded", handlers.ListDownloaded)
+ r.Post("/delete", handlers.DeleteFile)
+ r.Get("/d/{id}", handlers.SendFile)
})
// Authentication routes
r.Route("/auth", func(r chi.Router) {
- r.Post("/login", rest.Login)
- r.Get("/logout", rest.Logout)
+ r.Post("/login", handlers.Login)
+ r.Get("/logout", handlers.Logout)
})
// RPC handlers
- r.Route("/rpc", func(r chi.Router) {
- r.Use(middlewares.Authenticated)
- r.Get("/ws", ytdlpRPC.WebSocket)
- r.Post("/http", ytdlpRPC.Post)
- })
+ r.Route("/rpc", ytdlpRPC.ApplyRouter())
+
+ // REST API handlers
+ r.Route("/api/v1", rest.ApplyRouter(c.db, c.mq))
return &http.Server{
Addr: fmt.Sprintf(":%d", c.port),