From 8a790043f8a5cc75de1ed4141705e655bb36efb9 Mon Sep 17 00:00:00 2001
From: Ryan Whytsell
Date: Tue, 21 Oct 2025 13:42:04 -0400
Subject: [PATCH] Claude's first attempt on webdav
---
CLAUDE.md | 96 ++++++++-
backend/package.json | 3 +-
backend/src/db/database.ts | 7 +-
backend/src/routes/config.ts | 29 +++
backend/src/routes/videos.ts | 138 ++++++++++++-
backend/src/services/scheduler.ts | 37 +++-
backend/src/services/webdav.ts | 265 +++++++++++++++++++++++++
frontend/src/pages/SettingsPage.tsx | 138 +++++++++++++
frontend/src/pages/VideoPlayerPage.tsx | 2 +-
frontend/src/services/api.ts | 6 +
frontend/src/types/index.ts | 5 +
11 files changed, 713 insertions(+), 13 deletions(-)
create mode 100644 backend/src/services/webdav.ts
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 {