diff --git a/CLAUDE.md b/CLAUDE.md index 7623722..35d68ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,10 +71,10 @@ This is configured in `frontend/vite.config.ts`. Production deployments need a r Routes are split by domain: - `backend/src/routes/channels.ts`: Channel CRUD + refresh -- `backend/src/routes/videos.ts`: Video CRUD + retry -- `backend/src/routes/config.ts`: Settings + scheduler control +- `backend/src/routes/videos.ts`: Video CRUD + retry + streaming +- `backend/src/routes/config.ts`: Settings + scheduler control + WebDAV test -Video files are served statically from `backend/downloads/` directory. +Video files are served via `/api/videos/:id/stream` endpoint which handles both local storage and WebDAV proxying. ## Key Constraints @@ -103,7 +103,8 @@ Adding a channel fetches ALL videos from that channel using yt-dlp's `--flat-pla ## File Locations - Database: `backend/data.db` -- Downloaded videos: `backend/downloads/{videoId}.mp4` +- Downloaded videos (local mode): `backend/downloads/{videoId}.mp4` +- Downloaded videos (WebDAV mode): Stored on WebDAV server, proxied through backend - Environment: Optional `backend/.env` (PORT, NODE_ENV) ## System Requirements @@ -153,3 +154,90 @@ For persistent 403 errors, export cookies from your browser: - Increase download interval to avoid rate limiting - Use variance to randomize download times - Avoid downloading too many videos from the same channel quickly + +## WebDAV Storage + +VidRip supports storing videos on a WebDAV server instead of local storage. This is useful for: +- Network-attached storage (NAS) devices +- Cloud storage with WebDAV support (Nextcloud, ownCloud) +- Remote servers with WebDAV access + +### Enabling WebDAV + +1. Go to Settings page in the UI +2. Enable "WebDAV Storage" +3. Configure connection details: + - **Server URL**: Full WebDAV server URL (e.g., `https://nextcloud.example.com/remote.php/dav/files/username`) + - **Username**: WebDAV authentication username + - **Password**: WebDAV authentication password + - **Directory Path**: Subdirectory on server (default: `/vidrip/`) +4. Click "Test Connection" to verify settings +5. Save Settings + +### How WebDAV Works + +**Download Flow**: +1. Video is downloaded to `backend/downloads/` temporarily +2. After successful download, video is uploaded to WebDAV server +3. Local temporary file is deleted after successful upload +4. Database stores path as `webdav://{videoId}.mp4` + +**Playback Flow**: +1. Frontend requests `/api/videos/:id/stream` +2. Backend detects WebDAV storage (path starts with `webdav://`) +3. Backend creates stream from WebDAV server +4. Stream is proxied to frontend (transparent to user) + +**Deletion Flow**: +- When deleting a video, backend automatically removes from WebDAV server +- Database record is also deleted + +### Supported WebDAV Servers + +Tested with: +- **Nextcloud**: Use WebDAV URL format: `https://your-nextcloud.com/remote.php/dav/files/username` +- **ownCloud**: Similar to Nextcloud +- **Generic WebDAV**: Any RFC-compliant WebDAV server + +### Configuration Details + +WebDAV settings are stored in the `config` table: +- `webdavEnabled`: 'true' or 'false' +- `webdavUrl`: Server URL +- `webdavUsername`: Username +- `webdavPassword`: Password (stored in plaintext in database) +- `webdavPath`: Directory path on server + +### Troubleshooting WebDAV + +**Connection Test Fails**: +- Verify server URL is correct and accessible from backend server +- Check username/password are correct +- Ensure WebDAV path exists or backend has permission to create it +- Check firewall rules if server is remote + +**Upload Fails**: +- Check available disk space on WebDAV server +- Verify write permissions on directory +- Check WebDAV server logs for detailed errors +- Ensure network is stable for large uploads + +**Playback Issues**: +- Verify WebDAV server is accessible from backend +- Check network bandwidth between backend and WebDAV +- Browser console may show streaming errors +- Try downloading video directly from WebDAV to verify file integrity + +**Migration**: +- Existing local videos remain in `backend/downloads/` +- New videos go to WebDAV when enabled +- To migrate existing videos, manually upload to WebDAV and update database `filePath` +- Or delete and re-download videos + +### Security Considerations + +- Credentials stored in local database (plaintext) +- Backend must have network access to WebDAV server +- Use HTTPS for WebDAV URL to encrypt credentials in transit +- Consider firewall rules to restrict WebDAV access to backend IP only +- WebDAV server should require authentication diff --git a/backend/package.json b/backend/package.json index b8541c5..04baedf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,8 @@ "cors": "^2.8.5", "better-sqlite3": "^9.2.2", "node-cron": "^3.0.3", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "webdav": "^5.3.0" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/backend/src/db/database.ts b/backend/src/db/database.ts index be52f89..a6792c6 100644 --- a/backend/src/db/database.ts +++ b/backend/src/db/database.ts @@ -89,7 +89,12 @@ export function initDatabase() { intervalMinutes: '180', // 180 minutes = 3 hours varianceMinutes: '30', maxConcurrentDownloads: '1', - enabled: 'true' + enabled: 'true', + webdavEnabled: 'false', + webdavUrl: '', + webdavUsername: '', + webdavPassword: '', + webdavPath: '/vidrip/' }; const insertConfig = db.prepare( diff --git a/backend/src/routes/config.ts b/backend/src/routes/config.ts index f825eea..9801404 100644 --- a/backend/src/routes/config.ts +++ b/backend/src/routes/config.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { configOperations } from '../db/database'; import { restartScheduler, getSchedulerStatus } from '../services/scheduler'; +import { testConnection } from '../services/webdav'; const router = Router(); @@ -46,4 +47,32 @@ router.get('/scheduler/status', (req, res) => { } }); +// Test WebDAV connection +router.post('/webdav/test', async (req, res) => { + try { + const { url, username, password, path } = req.body; + + if (!url || !username || !password) { + return res.status(400).json({ + success: false, + message: 'URL, username, and password are required' + }); + } + + const result = await testConnection({ + url, + username, + password, + path: path || '/vidrip/' + }); + + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Failed to test connection' + }); + } +}); + export default router; diff --git a/backend/src/routes/videos.ts b/backend/src/routes/videos.ts index 91ca91b..af0cd89 100644 --- a/backend/src/routes/videos.ts +++ b/backend/src/routes/videos.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { videoOperations } from '../db/database'; import { getVideoInfo, downloadVideo } from '../services/ytdlp'; +import { isWebDAVEnabled, uploadVideo as uploadToWebDAV, deleteVideo as deleteFromWebDAV, streamVideo as streamFromWebDAV, getVideoStats } from '../services/webdav'; import fs from 'fs'; import path from 'path'; @@ -90,7 +91,7 @@ router.post('/', async (req, res) => { }); // Delete video -router.delete('/:id', (req, res) => { +router.delete('/:id', async (req, res) => { try { const id = parseInt(req.params.id); @@ -99,9 +100,22 @@ router.delete('/:id', (req, res) => { return res.status(404).json({ error: 'Video not found' }); } - // Delete file if it exists - if (video.filePath && fs.existsSync(video.filePath)) { - fs.unlinkSync(video.filePath); + // Delete file based on storage type + if (video.filePath) { + if (video.filePath.startsWith('webdav://')) { + // Delete from WebDAV + try { + await deleteFromWebDAV(video.videoId); + console.log(`Deleted ${video.videoId} from WebDAV`); + } catch (error) { + console.error('Failed to delete from WebDAV:', error); + // Continue with database deletion even if WebDAV delete fails + } + } else if (fs.existsSync(video.filePath)) { + // Delete local file + fs.unlinkSync(video.filePath); + console.log(`Deleted local file: ${video.filePath}`); + } } videoOperations.delete(id); @@ -164,7 +178,37 @@ router.post('/:id/download', async (req, res) => { videoOperations.updateStatus(id, 'downloading'); const result = await downloadVideo(video.videoId, video.url); - videoOperations.markCompleted(id, result.filePath, result.fileSize); + + // Check if WebDAV is enabled + if (isWebDAVEnabled()) { + console.log(`Uploading ${video.videoId} to WebDAV...`); + try { + await uploadToWebDAV(result.filePath, video.videoId); + console.log(`Successfully uploaded ${video.videoId} to WebDAV`); + + // Delete local file after successful upload + if (fs.existsSync(result.filePath)) { + fs.unlinkSync(result.filePath); + console.log(`Deleted local file: ${result.filePath}`); + } + + // Mark as completed with WebDAV path + videoOperations.markCompleted(id, `webdav://${video.videoId}.mp4`, result.fileSize); + } catch (uploadError) { + console.error('WebDAV upload failed:', uploadError); + // Delete local file and mark as failed + if (fs.existsSync(result.filePath)) { + fs.unlinkSync(result.filePath); + } + throw new Error( + `WebDAV upload failed: ${uploadError instanceof Error ? uploadError.message : 'Unknown error'}` + ); + } + } else { + // Local storage mode + videoOperations.markCompleted(id, result.filePath, result.fileSize); + } + console.log(`Immediate download completed: ${video.title}`); } catch (error) { console.error('Immediate download error:', error); @@ -186,4 +230,88 @@ router.post('/:id/download', async (req, res) => { } }); +// Stream video (WebDAV proxy or local file) +router.get('/:id/stream', async (req, res) => { + try { + const id = parseInt(req.params.id); + const video = videoOperations.getById(id); + + if (!video) { + return res.status(404).json({ error: 'Video not found' }); + } + + if (video.status !== 'completed') { + return res.status(400).json({ error: 'Video not yet downloaded' }); + } + + // Check if video is stored on WebDAV (filePath starts with webdav://) + if (video.filePath && video.filePath.startsWith('webdav://')) { + // Stream from WebDAV + try { + const stream = await streamFromWebDAV(video.videoId); + const stats = await getVideoStats(video.videoId); + + // Set headers + res.setHeader('Content-Type', 'video/mp4'); + if (stats && stats.size) { + res.setHeader('Content-Length', stats.size.toString()); + } + res.setHeader('Accept-Ranges', 'bytes'); + + // Pipe stream to response + stream.pipe(res); + + stream.on('error', (error) => { + console.error('Stream error:', error); + if (!res.headersSent) { + res.status(500).json({ error: 'Failed to stream video' }); + } + }); + } catch (error) { + console.error('Failed to stream from WebDAV:', error); + res.status(500).json({ error: 'Failed to stream video from WebDAV' }); + } + } else if (video.filePath && fs.existsSync(video.filePath)) { + // Stream from local file + const stat = fs.statSync(video.filePath); + const fileSize = stat.size; + const range = req.headers.range; + + if (range) { + // Handle range requests for seeking + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunkSize = (end - start) + 1; + const file = fs.createReadStream(video.filePath, { start, end }); + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunkSize, + 'Content-Type': 'video/mp4', + }); + + file.pipe(res); + } else { + // No range request - stream entire file + res.writeHead(200, { + 'Content-Length': fileSize, + 'Content-Type': 'video/mp4', + 'Accept-Ranges': 'bytes', + }); + + fs.createReadStream(video.filePath).pipe(res); + } + } else { + res.status(404).json({ error: 'Video file not found' }); + } + } catch (error) { + console.error('Stream error:', error); + if (!res.headersSent) { + res.status(500).json({ error: 'Failed to stream video' }); + } + } +}); + export default router; diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 9023c79..8c0f866 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -6,6 +6,8 @@ import { } from "../db/database"; import { downloadVideo, getChannelVideos } from "./ytdlp"; import { DownloadProgress } from "../types"; +import { isWebDAVEnabled, uploadVideo as uploadToWebDAV } from "./webdav"; +import fs from "fs"; let schedulerTask: cron.ScheduledTask | null = null; let isDownloading = false; @@ -58,7 +60,40 @@ async function downloadNextVideo() { }, }); - videoOperations.markCompleted(video.id, result.filePath, result.fileSize); + // Check if WebDAV is enabled + if (isWebDAVEnabled()) { + console.log(`Uploading ${video.videoId} to WebDAV...`); + try { + await uploadToWebDAV(result.filePath, video.videoId); + console.log(`Successfully uploaded ${video.videoId} to WebDAV`); + + // Delete local file after successful upload + if (fs.existsSync(result.filePath)) { + fs.unlinkSync(result.filePath); + console.log(`Deleted local file: ${result.filePath}`); + } + + // Mark as completed with WebDAV path + videoOperations.markCompleted( + video.id, + `webdav://${video.videoId}.mp4`, + result.fileSize, + ); + } catch (uploadError) { + console.error("WebDAV upload failed:", uploadError); + // Delete local file and mark as failed + if (fs.existsSync(result.filePath)) { + fs.unlinkSync(result.filePath); + } + throw new Error( + `WebDAV upload failed: ${uploadError instanceof Error ? uploadError.message : "Unknown error"}`, + ); + } + } else { + // Local storage mode + videoOperations.markCompleted(video.id, result.filePath, result.fileSize); + } + console.log(`Download completed: ${video.title}`); currentProgress = null; } catch (error) { diff --git a/backend/src/services/webdav.ts b/backend/src/services/webdav.ts new file mode 100644 index 0000000..9f2c6b3 --- /dev/null +++ b/backend/src/services/webdav.ts @@ -0,0 +1,265 @@ +import { createClient, WebDAVClient, FileStat } from 'webdav'; +import { createReadStream, createWriteStream, statSync } from 'fs'; +import { Readable } from 'stream'; +import { configOperations } from '../db/database'; + +let webdavClient: WebDAVClient | null = null; + +export interface WebDAVConfig { + url: string; + username: string; + password: string; + path: string; +} + +/** + * Get WebDAV configuration from database + */ +function getWebDAVConfig(): WebDAVConfig | null { + const config = configOperations.getAll(); + + if (config.webdavEnabled !== 'true') { + return null; + } + + if (!config.webdavUrl || !config.webdavUsername || !config.webdavPassword) { + return null; + } + + return { + url: config.webdavUrl, + username: config.webdavUsername, + password: config.webdavPassword, + path: config.webdavPath || '/vidrip/' + }; +} + +/** + * Create authenticated WebDAV client from config + */ +export function createWebDAVClient(config?: WebDAVConfig): WebDAVClient | null { + const webdavConfig = config || getWebDAVConfig(); + + if (!webdavConfig) { + return null; + } + + try { + return createClient(webdavConfig.url, { + username: webdavConfig.username, + password: webdavConfig.password + }); + } catch (error) { + console.error('Failed to create WebDAV client:', error); + return null; + } +} + +/** + * Test WebDAV connection and permissions + */ +export async function testConnection(config: WebDAVConfig): Promise<{ success: boolean; message: string }> { + try { + const client = createWebDAVClient(config); + + if (!client) { + return { success: false, message: 'Failed to create WebDAV client' }; + } + + // Ensure path ends with / + const basePath = config.path.endsWith('/') ? config.path : `${config.path}/`; + + // Test connection by checking if base path exists + const exists = await client.exists(basePath); + + if (!exists) { + // Try to create the directory + try { + await client.createDirectory(basePath, { recursive: true }); + return { success: true, message: 'Connected successfully and created directory' }; + } catch (createError) { + return { success: false, message: `Directory doesn't exist and cannot be created: ${createError}` }; + } + } + + // Try to get directory contents to verify read permissions + await client.getDirectoryContents(basePath); + + return { success: true, message: 'Connected successfully' }; + } catch (error) { + console.error('WebDAV connection test failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Connection failed' + }; + } +} + +/** + * Get full WebDAV path for a video + */ +function getVideoPath(videoId: string): string { + const config = getWebDAVConfig(); + if (!config) { + throw new Error('WebDAV not configured'); + } + + const basePath = config.path.endsWith('/') ? config.path : `${config.path}/`; + return `${basePath}${videoId}.mp4`; +} + +/** + * Upload video file to WebDAV + */ +export async function uploadVideo(localPath: string, videoId: string): Promise { + const config = getWebDAVConfig(); + + if (!config) { + throw new Error('WebDAV not configured'); + } + + const client = createWebDAVClient(config); + + if (!client) { + throw new Error('Failed to create WebDAV client'); + } + + try { + const remotePath = getVideoPath(videoId); + const basePath = config.path.endsWith('/') ? config.path : `${config.path}/`; + + // Ensure directory exists + const dirExists = await client.exists(basePath); + if (!dirExists) { + await client.createDirectory(basePath, { recursive: true }); + } + + // Upload file + const fileStream = createReadStream(localPath); + await client.putFileContents(remotePath, fileStream, { + overwrite: true + }); + + console.log(`Successfully uploaded ${videoId}.mp4 to WebDAV`); + } catch (error) { + console.error('Failed to upload to WebDAV:', error); + throw new Error(`WebDAV upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Delete video from WebDAV server + */ +export async function deleteVideo(videoId: string): Promise { + const config = getWebDAVConfig(); + + if (!config) { + // If WebDAV not configured, silently return (nothing to delete) + return; + } + + const client = createWebDAVClient(config); + + if (!client) { + throw new Error('Failed to create WebDAV client'); + } + + try { + const remotePath = getVideoPath(videoId); + + const exists = await client.exists(remotePath); + if (exists) { + await client.deleteFile(remotePath); + console.log(`Successfully deleted ${videoId}.mp4 from WebDAV`); + } + } catch (error) { + console.error('Failed to delete from WebDAV:', error); + throw new Error(`WebDAV delete failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Get readable stream from WebDAV for proxying + */ +export async function streamVideo(videoId: string): Promise { + const config = getWebDAVConfig(); + + if (!config) { + throw new Error('WebDAV not configured'); + } + + const client = createWebDAVClient(config); + + if (!client) { + throw new Error('Failed to create WebDAV client'); + } + + try { + const remotePath = getVideoPath(videoId); + + const stream = client.createReadStream(remotePath); + return stream as Readable; + } catch (error) { + console.error('Failed to stream from WebDAV:', error); + throw new Error(`WebDAV stream failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Check if video exists on WebDAV + */ +export async function getVideoExists(videoId: string): Promise { + const config = getWebDAVConfig(); + + if (!config) { + return false; + } + + const client = createWebDAVClient(config); + + if (!client) { + return false; + } + + try { + const remotePath = getVideoPath(videoId); + return await client.exists(remotePath); + } catch (error) { + console.error('Failed to check video existence on WebDAV:', error); + return false; + } +} + +/** + * Get video file stats from WebDAV + */ +export async function getVideoStats(videoId: string): Promise { + const config = getWebDAVConfig(); + + if (!config) { + return null; + } + + const client = createWebDAVClient(config); + + if (!client) { + return null; + } + + try { + const remotePath = getVideoPath(videoId); + const stat = await client.stat(remotePath) as FileStat; + return stat; + } catch (error) { + console.error('Failed to get video stats from WebDAV:', error); + return null; + } +} + +/** + * Check if WebDAV is enabled and configured + */ +export function isWebDAVEnabled(): boolean { + const config = getWebDAVConfig(); + return config !== null; +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 3f47cf7..01a5d8e 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -9,6 +9,8 @@ function SettingsPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); + const [testingWebDAV, setTestingWebDAV] = useState(false); + const [webdavTestResult, setWebdavTestResult] = useState<{ success: boolean; message: string } | null>(null); useEffect(() => { loadConfig(); @@ -61,6 +63,31 @@ function SettingsPage() { const handleChange = (key: keyof Config, value: string) => { if (!config) return; setConfig({ ...config, [key]: value }); + // Clear test result when config changes + setWebdavTestResult(null); + }; + + const handleTestWebDAV = async () => { + if (!config) return; + + try { + setTestingWebDAV(true); + setWebdavTestResult(null); + const result = await configAPI.testWebDAVConnection({ + url: config.webdavUrl, + username: config.webdavUsername, + password: config.webdavPassword, + path: config.webdavPath || '/vidrip/' + }); + setWebdavTestResult(result); + } catch (err) { + setWebdavTestResult({ + success: false, + message: err instanceof Error ? err.message : 'Test failed' + }); + } finally { + setTestingWebDAV(false); + } }; if (loading) { @@ -224,6 +251,117 @@ function SettingsPage() { Number of videos to download simultaneously (recommended: 1)

+ +
+

WebDAV Storage

+ +
+
+ +

+ Store videos on a WebDAV server instead of local storage +

+
+ + {config.webdavEnabled === 'true' && ( + <> +
+ + handleChange('webdavUrl', e.target.value)} + placeholder="https://webdav.example.com" + className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +
+ +
+ + handleChange('webdavUsername', e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +
+ +
+ + handleChange('webdavPassword', e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +
+ +
+ + handleChange('webdavPath', e.target.value)} + placeholder="/vidrip/" + className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +

+ Directory path on the WebDAV server where videos will be stored +

+
+ +
+ + + {webdavTestResult && ( +
+ {webdavTestResult.message} +
+ )} +
+ +
+

Important Notes

+
    +
  • Videos will be uploaded to WebDAV after downloading
  • +
  • Local copies are deleted after successful upload
  • +
  • Backend must have network access to the WebDAV server
  • +
  • Credentials are stored in the local database
  • +
+
+ + )} +
+
diff --git a/frontend/src/pages/VideoPlayerPage.tsx b/frontend/src/pages/VideoPlayerPage.tsx index 269a9a1..3c8d1f8 100644 --- a/frontend/src/pages/VideoPlayerPage.tsx +++ b/frontend/src/pages/VideoPlayerPage.tsx @@ -98,7 +98,7 @@ function VideoPlayerPage() { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f41e61e..062aac5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -135,4 +135,10 @@ export const configAPI = { getSchedulerStatus: () => fetchJSON(`${API_BASE}/config/scheduler/status`), + + testWebDAVConnection: (config: { url: string; username: string; password: string; path: string }) => + fetchJSON<{ success: boolean; message: string }>(`${API_BASE}/config/webdav/test`, { + method: 'POST', + body: JSON.stringify(config), + }), }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ae9e1b5..fb208d2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -41,6 +41,11 @@ export interface Config { varianceMinutes: string; maxConcurrentDownloads: string; enabled: string; + webdavEnabled: string; + webdavUrl: string; + webdavUsername: string; + webdavPassword: string; + webdavPath: string; } export interface SchedulerStatus {