feat: yt-dlp cookie management UI + Docker sidecar fixes
- Add settings routes for uploading/deleting yt-dlp cookie files (admin only) - Add YouTube tab in Settings page with file upload, paste, and delete - Enable --remote-components ejs:github for YouTube bot-detection bypass - Pass cookies to resolveVideoUrl for video streaming yt-dlp calls - Fix SidecarClient to accept full URL string (Docker networking) - Load saved cookie file from data dir on startup - Switch docker-compose sidecar from build to prebuilt image - Add docker-compose.dev.yml for dev deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8cbff01d18
commit
c2746f6e97
@@ -0,0 +1,57 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: clusterzx/ts6-manager:backend-dev
|
||||
container_name: ts6-backend-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- DATABASE_URL=file:/app/packages/backend/data/ts6webui.db
|
||||
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env or environment}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
|
||||
- JWT_ACCESS_EXPIRY=15m
|
||||
- JWT_REFRESH_EXPIRY=7d
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3090}
|
||||
- MUSIC_DIR=/data/music
|
||||
- SIDECAR_URL=http://ts6-sidecar-dev:9800
|
||||
volumes:
|
||||
- backend-data:/app/packages/backend/data
|
||||
- music-data:/data/music
|
||||
ports:
|
||||
- "3091:3001"
|
||||
depends_on:
|
||||
- sidecar
|
||||
networks:
|
||||
- ts6-network
|
||||
|
||||
sidecar:
|
||||
image: clusterzx/ts6-manager:sidecar-dev
|
||||
container_name: ts6-sidecar-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SIDECAR_PORT=9800
|
||||
networks:
|
||||
- ts6-network
|
||||
|
||||
frontend:
|
||||
image: clusterzx/ts6-manager:frontend-dev
|
||||
container_name: ts6-frontend-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3090:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- ts6-network
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
driver: local
|
||||
music-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
ts6-network:
|
||||
driver: bridge
|
||||
+1
-3
@@ -32,9 +32,7 @@ services:
|
||||
- ts6-network
|
||||
|
||||
sidecar:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.sidecar
|
||||
image: clusterzx/ts6-manager:sidecar
|
||||
container_name: ts6-sidecar
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -31,6 +31,7 @@ import { musicRequestRoutes } from './routes/music-requests.routes.js';
|
||||
import { widgetPublicRoutes } from './routes/widget-public.routes.js';
|
||||
import { widgetRoutes } from './routes/widget.routes.js';
|
||||
import { setupRoutes } from './routes/setup.routes.js';
|
||||
import { settingsRoutes } from './routes/settings.routes.js';
|
||||
import { requireServerAccess } from './middleware/server-access.js';
|
||||
|
||||
export function createApp(): Express {
|
||||
@@ -101,6 +102,7 @@ export function createApp(): Express {
|
||||
app.use('/api/servers/:configId/radio-stations', serverAccess, radioStationRoutes);
|
||||
app.use('/api/servers/:configId/music-requests', serverAccess, musicRequestRoutes);
|
||||
app.use('/api/widgets', widgetRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { config } from './config.js';
|
||||
import { setYtCookieFile } from './voice/audio/youtube.js';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function main() {
|
||||
// C1: JWT secret startup guard
|
||||
@@ -21,15 +22,17 @@ async function main() {
|
||||
console.warn('[WARN] JWT_SECRET is using the default development value. Set JWT_SECRET in production!');
|
||||
}
|
||||
|
||||
// Configure yt-dlp cookie file if provided
|
||||
// Configure yt-dlp cookie file: env var takes priority, then saved file from data dir
|
||||
const cookiePath = process.env.YT_COOKIE_FILE;
|
||||
if (cookiePath) {
|
||||
if (fs.existsSync(cookiePath)) {
|
||||
setYtCookieFile(cookiePath);
|
||||
console.log(`[yt-dlp] Using cookie file: ${cookiePath}`);
|
||||
} else {
|
||||
console.warn(`[yt-dlp] Cookie file not found: ${cookiePath}`);
|
||||
}
|
||||
const savedCookiePath = path.resolve('data', 'yt-cookies.txt');
|
||||
if (cookiePath && fs.existsSync(cookiePath)) {
|
||||
setYtCookieFile(cookiePath);
|
||||
console.log(`[yt-dlp] Using cookie file (env): ${cookiePath}`);
|
||||
} else if (fs.existsSync(savedCookiePath)) {
|
||||
setYtCookieFile(savedCookiePath);
|
||||
console.log(`[yt-dlp] Using saved cookie file: ${savedCookiePath}`);
|
||||
} else if (cookiePath) {
|
||||
console.warn(`[yt-dlp] Cookie file not found: ${cookiePath}`);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Settings routes — app-wide configuration (admin only).
|
||||
* Currently handles yt-dlp cookie file management.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import { setYtCookieFile, getYtCookieFile } from '../voice/audio/youtube.js';
|
||||
|
||||
const settingsRoutes: Router = Router();
|
||||
|
||||
// Cookie file stored in the backend data directory (persisted in Docker volume)
|
||||
const COOKIE_DIR = path.resolve('data');
|
||||
const COOKIE_PATH = path.join(COOKIE_DIR, 'yt-cookies.txt');
|
||||
|
||||
const upload = multer({
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max
|
||||
storage: multer.memoryStorage(),
|
||||
});
|
||||
|
||||
// Admin-only guard
|
||||
function requireAdmin(req: Request, _res: Response, next: Function) {
|
||||
if ((req as any).user?.role !== 'admin') {
|
||||
return next(new AppError(403, 'Admin access required'));
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /api/settings/yt-cookies — Check cookie file status
|
||||
settingsRoutes.get('/yt-cookies', requireAdmin, (_req: Request, res: Response) => {
|
||||
const exists = fs.existsSync(COOKIE_PATH);
|
||||
const activePath = getYtCookieFile();
|
||||
res.json({
|
||||
active: !!activePath,
|
||||
exists,
|
||||
size: exists ? fs.statSync(COOKIE_PATH).size : 0,
|
||||
path: activePath,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/settings/yt-cookies — Upload cookie file
|
||||
settingsRoutes.post('/yt-cookies', requireAdmin, upload.single('cookies'), (req: Request, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
// Check if raw text was sent in body
|
||||
const text = req.body?.text;
|
||||
if (!text || typeof text !== 'string') {
|
||||
throw new AppError(400, 'No cookie file or text provided');
|
||||
}
|
||||
fs.mkdirSync(COOKIE_DIR, { recursive: true });
|
||||
fs.writeFileSync(COOKIE_PATH, text, 'utf-8');
|
||||
} else {
|
||||
fs.mkdirSync(COOKIE_DIR, { recursive: true });
|
||||
fs.writeFileSync(COOKIE_PATH, req.file.buffer);
|
||||
}
|
||||
|
||||
setYtCookieFile(COOKIE_PATH);
|
||||
const size = fs.statSync(COOKIE_PATH).size;
|
||||
console.log(`[yt-dlp] Cookie file uploaded (${size} bytes)`);
|
||||
res.json({ success: true, size });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/settings/yt-cookies — Remove cookie file
|
||||
settingsRoutes.delete('/yt-cookies', requireAdmin, (_req: Request, res: Response, next) => {
|
||||
try {
|
||||
if (fs.existsSync(COOKIE_PATH)) {
|
||||
fs.unlinkSync(COOKIE_PATH);
|
||||
}
|
||||
setYtCookieFile(null);
|
||||
console.log('[yt-dlp] Cookie file removed');
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { settingsRoutes };
|
||||
@@ -26,8 +26,16 @@ export function setYtCookieFile(filePath: string | null): void {
|
||||
ytCookieFile = filePath;
|
||||
}
|
||||
|
||||
function getCookieArgs(): string[] {
|
||||
return ytCookieFile ? ["--cookies", ytCookieFile] : [];
|
||||
export function getYtCookieFile(): string | null {
|
||||
return ytCookieFile;
|
||||
}
|
||||
|
||||
export function getCookieArgs(): string[] {
|
||||
const args: string[] = ["--remote-components", "ejs:github"];
|
||||
if (ytCookieFile) {
|
||||
args.push("--cookies", ytCookieFile);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,10 @@ export interface SidecarStats {
|
||||
export class SidecarClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(port: number = 9800) {
|
||||
this.baseUrl = `http://127.0.0.1:${port}`;
|
||||
constructor(portOrUrl: number | string = 9800) {
|
||||
this.baseUrl = typeof portOrUrl === 'string'
|
||||
? portOrUrl.replace(/\/+$/, '')
|
||||
: `http://127.0.0.1:${portOrUrl}`;
|
||||
}
|
||||
|
||||
async waitHealthy(timeoutMs: number = 6000): Promise<void> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StreamSignaling, type ActiveStream, type SignalingMessage } from './str
|
||||
import { SidecarClient } from './streaming/sidecar-client.js';
|
||||
import { SidecarProcess, type SidecarConfig } from './streaming/sidecar-process.js';
|
||||
import { STREAM_PRESETS, DEFAULT_PRESET, type VideoViewerInfo, type VideoStreamStatus } from './streaming/types.js';
|
||||
import { getCookieArgs } from './audio/youtube.js';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
/** Resolve a YouTube/yt-dlp-compatible URL to a direct stream URL */
|
||||
@@ -20,6 +21,7 @@ function resolveVideoUrl(url: string, maxHeight: number = 720): Promise<string>
|
||||
// Request best combined format (video+audio) up to the target height
|
||||
const formatFilter = `best[height<=${maxHeight}][ext=mp4]/best[height<=${maxHeight}]/best[ext=mp4]/best`;
|
||||
const proc = spawn('yt-dlp', [
|
||||
...getCookieArgs(),
|
||||
'-f', formatFilter,
|
||||
'--no-playlist',
|
||||
'-g', // print direct URL only
|
||||
@@ -769,8 +771,7 @@ export class VoiceBot extends EventEmitter {
|
||||
|
||||
if (sidecarUrl) {
|
||||
// Docker mode: sidecar is an external service, don't spawn it
|
||||
const url = new URL(sidecarUrl);
|
||||
this.sidecarHttp = new SidecarClient(parseInt(url.port) || 9800);
|
||||
this.sidecarHttp = new SidecarClient(sidecarUrl);
|
||||
} else {
|
||||
// Local mode: spawn sidecar binary
|
||||
const sidecarConfig: SidecarConfig = {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import api from './client';
|
||||
|
||||
export const settingsApi = {
|
||||
getYtCookieStatus: () => api.get('/settings/yt-cookies').then((r) => r.data),
|
||||
|
||||
uploadYtCookieFile: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('cookies', file);
|
||||
return api.post('/settings/yt-cookies', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then((r) => r.data);
|
||||
},
|
||||
|
||||
uploadYtCookieText: (text: string) =>
|
||||
api.post('/settings/yt-cookies', { text }).then((r) => r.data),
|
||||
|
||||
deleteYtCookies: () => api.delete('/settings/yt-cookies').then((r) => r.data),
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usersApi } from '@/api/bots.api';
|
||||
import { authApi } from '@/api/auth.api';
|
||||
import { serversApi } from '@/api/servers.api';
|
||||
import { settingsApi } from '@/api/settings.api';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { PageLoader } from '@/components/shared/LoadingSpinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -15,7 +16,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { ConfirmDialog } from '@/components/shared/ConfirmDialog';
|
||||
import { Settings as SettingsIcon, Users, Server, Plus, Trash2, Pencil, TestTube, Check, X, Lock, KeyRound } from 'lucide-react';
|
||||
import { Settings as SettingsIcon, Users, Server, Plus, Trash2, Pencil, TestTube, Check, X, Lock, KeyRound, Youtube, Upload, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Settings() {
|
||||
@@ -31,6 +32,7 @@ export default function Settings() {
|
||||
{isAdmin && <TabsTrigger value="connections"><Server className="h-3.5 w-3.5 mr-1" /> Connections</TabsTrigger>}
|
||||
<TabsTrigger value="account"><Lock className="h-3.5 w-3.5 mr-1" /> Account</TabsTrigger>
|
||||
{isAdmin && <TabsTrigger value="users"><Users className="h-3.5 w-3.5 mr-1" /> Users</TabsTrigger>}
|
||||
{isAdmin && <TabsTrigger value="youtube"><Youtube className="h-3.5 w-3.5 mr-1" /> YouTube</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
{isAdmin && (
|
||||
@@ -48,6 +50,12 @@ export default function Settings() {
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="youtube" className="mt-4">
|
||||
<YouTubeTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
@@ -434,3 +442,145 @@ function UsersTab() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YouTubeTab() {
|
||||
const qc = useQueryClient();
|
||||
const [pasteMode, setPasteMode] = useState(false);
|
||||
const [cookieText, setCookieText] = useState('');
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['yt-cookie-status'],
|
||||
queryFn: settingsApi.getYtCookieStatus,
|
||||
});
|
||||
|
||||
const uploadFile = useMutation({
|
||||
mutationFn: (file: File) => settingsApi.uploadYtCookieFile(file),
|
||||
onSuccess: () => {
|
||||
toast.success('Cookie file uploaded');
|
||||
qc.invalidateQueries({ queryKey: ['yt-cookie-status'] });
|
||||
},
|
||||
onError: () => toast.error('Failed to upload cookie file'),
|
||||
});
|
||||
|
||||
const uploadText = useMutation({
|
||||
mutationFn: (text: string) => settingsApi.uploadYtCookieText(text),
|
||||
onSuccess: () => {
|
||||
toast.success('Cookies saved');
|
||||
setCookieText('');
|
||||
setPasteMode(false);
|
||||
qc.invalidateQueries({ queryKey: ['yt-cookie-status'] });
|
||||
},
|
||||
onError: () => toast.error('Failed to save cookies'),
|
||||
});
|
||||
|
||||
const deleteCookies = useMutation({
|
||||
mutationFn: () => settingsApi.deleteYtCookies(),
|
||||
onSuccess: () => {
|
||||
toast.success('Cookie file removed');
|
||||
qc.invalidateQueries({ queryKey: ['yt-cookie-status'] });
|
||||
},
|
||||
onError: () => toast.error('Failed to remove cookies'),
|
||||
});
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadFile.mutate(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">YouTube Cookies</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload a cookies.txt file to access age-restricted or member-only YouTube content.
|
||||
You can export cookies from your browser using extensions like
|
||||
{' '}<span className="font-medium">Get cookies.txt LOCALLY</span> (Chrome/Firefox).
|
||||
</p>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${status?.active ? 'bg-green-500' : 'bg-zinc-500'}`} />
|
||||
<span className="text-sm">
|
||||
{isLoading ? 'Loading...' : status?.active
|
||||
? `Cookies active (${formatSize(status.size)})`
|
||||
: 'No cookies configured'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,.cookies"
|
||||
className="hidden"
|
||||
id="cookie-file-input"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('cookie-file-input')?.click()}
|
||||
disabled={uploadFile.isPending}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-1" />
|
||||
{uploadFile.isPending ? 'Uploading...' : 'Upload cookies.txt'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPasteMode(!pasteMode)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-1" />
|
||||
Paste cookies
|
||||
</Button>
|
||||
{status?.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => deleteCookies.mutate()}
|
||||
disabled={deleteCookies.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paste mode */}
|
||||
{pasteMode && (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
className="w-full h-32 rounded-md border border-border bg-background px-3 py-2 text-xs font-mono resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="# Netscape HTTP Cookie File .youtube.com	TRUE	/	TRUE	0	COOKIE_NAME	COOKIE_VALUE"
|
||||
value={cookieText}
|
||||
onChange={(e) => setCookieText(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => uploadText.mutate(cookieText)}
|
||||
disabled={!cookieText.trim() || uploadText.isPending}
|
||||
>
|
||||
{uploadText.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setPasteMode(false); setCookieText(''); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user