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:
Clusterzx
2026-03-10 23:55:25 +01:00
co-authored by Claude Opus 4.6
parent 8cbff01d18
commit c2746f6e97
10 changed files with 336 additions and 18 deletions
+57
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -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);
+11 -8
View File
@@ -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 };
+10 -2
View File
@@ -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> {
+3 -2
View File
@@ -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 = {
+18
View File
@@ -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),
};
+151 -1
View File
@@ -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&#10;.youtube.com&#9;TRUE&#9;/&#9;TRUE&#9;0&#9;COOKIE_NAME&#9;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>
);
}