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
+ Store videos on a WebDAV server instead of local storage +
++ Directory path on the WebDAV server where videos will be stored +
+