feat: init vlc-sender

This commit is contained in:
Vadim Sobinin
2026-02-06 16:53:11 +03:00
commit e3f95c53d7
11 changed files with 1524 additions and 0 deletions

277
src/index.ts Normal file
View File

@@ -0,0 +1,277 @@
import express, { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import http from 'http';
import FormData from 'form-data';
const app = express();
const PORT = parseInt(process.env.PORT || '3000', 10);
const SUBNET_PREFIX = '192.168.1.';
// Parse volumes: "label1:/path1,label2:/path2" or just "/path1,/path2"
interface Volume {
label: string;
path: string;
}
function parseVolumes(): Volume[] {
const raw = process.env.MEDIA_PATHS || process.env.MEDIA_PATH || '/media';
return raw
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const colonIdx = entry.indexOf(':');
// If has label (e.g. "Torrents:/data/torrents")
// But skip Windows-like paths or bare /paths
if (colonIdx > 0 && !entry.startsWith('/')) {
return {
label: entry.substring(0, colonIdx),
path: entry.substring(colonIdx + 1),
};
}
// Just a path — use directory name as label
return {
label: path.basename(entry) || entry,
path: entry,
};
});
}
const VOLUMES = parseVolumes();
function findVolume(volumeLabel: string): Volume | undefined {
return VOLUMES.find((v) => v.label === volumeLabel);
}
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
interface FileEntry {
name: string;
size: number;
isDir: boolean;
}
app.get('/api/files', (req: Request, res: Response): void => {
const relativePath = (req.query.path as string) || '';
// Root level — list volumes
if (!relativePath) {
const volumes: FileEntry[] = VOLUMES.map((v) => ({
name: v.label,
size: 0,
isDir: true,
}));
res.json(volumes);
return;
}
// Parse: first segment is volume label, rest is subpath
const parts = relativePath.split('/');
const volumeLabel = parts[0];
const subPath = parts.slice(1).join('/');
const volume = findVolume(volumeLabel);
if (!volume) {
res.status(404).json({ error: `Volume not found: ${volumeLabel}` });
return;
}
const fullPath = path.join(volume.path, subPath);
const resolved = path.resolve(fullPath);
if (!resolved.startsWith(path.resolve(volume.path))) {
res.status(403).json({ error: 'Access denied' });
return;
}
if (!fs.existsSync(resolved)) {
console.error(`[files] Path does not exist: ${resolved}`);
res.status(404).json({ error: `Directory not found: ${resolved}` });
return;
}
try {
const entries = fs.readdirSync(resolved, { withFileTypes: true });
const files: FileEntry[] = entries
.filter((e) => !e.name.startsWith('.'))
.map((e) => {
const entryPath = path.join(resolved, e.name);
try {
const stat = fs.statSync(entryPath);
return {
name: e.name,
size: stat.size,
isDir: e.isDirectory(),
};
} catch {
return null;
}
})
.filter((e): e is FileEntry => e !== null)
.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
console.log(`[files] ${resolved}${files.length} entries`);
res.json(files);
} catch (err) {
console.error(`[files] Error reading ${resolved}:`, err);
res.status(500).json({ error: `Failed to read directory: ${String(err)}` });
}
});
interface SendRequest {
ip: string;
files: string[];
}
function resolveFilePath(filePath: string): string | null {
const parts = filePath.split('/');
const volumeLabel = parts[0];
const subPath = parts.slice(1).join('/');
const volume = findVolume(volumeLabel);
if (!volume) return null;
const full = path.resolve(path.join(volume.path, subPath));
if (!full.startsWith(path.resolve(volume.path))) return null;
return full;
}
function sendFileToVLC(
filePath: string,
vlcHost: string,
): Promise<{ file: string; success: boolean; error?: string }> {
return new Promise((resolve) => {
const resolved = resolveFilePath(filePath);
if (!resolved) {
resolve({ file: filePath, success: false, error: 'Access denied' });
return;
}
let stat: fs.Stats;
try {
stat = fs.statSync(resolved);
} catch {
resolve({ file: filePath, success: false, error: 'File not found' });
return;
}
const form = new FormData();
const fileStream = fs.createReadStream(resolved);
form.append('file', fileStream, {
filename: path.basename(resolved),
knownLength: stat.size,
});
const reqOptions: http.RequestOptions = {
hostname: vlcHost,
port: 80,
path: '/upload.json',
method: 'POST',
headers: form.getHeaders(),
};
const req = http.request(reqOptions, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve({ file: filePath, success: true });
} else {
resolve({
file: filePath,
success: false,
error: `VLC returned ${res.statusCode}: ${body}`,
});
}
});
});
req.on('error', (err: Error) => {
resolve({ file: filePath, success: false, error: err.message });
});
form.pipe(req);
});
}
app.post('/api/send', (req: Request, res: Response): void => {
const { ip, files } = req.body as SendRequest;
if (!ip || !files || !files.length) {
res.status(400).json({ error: 'ip and files are required' });
return;
}
const ipNum = parseInt(ip, 10);
if (isNaN(ipNum) || ipNum < 1 || ipNum > 254) {
res.status(400).json({ error: 'Invalid IP suffix' });
return;
}
const vlcHost = `${SUBNET_PREFIX}${ip}`;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const sendProgress = (data: Record<string, unknown>) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const totalFiles = files.length;
let completed = 0;
const processFiles = async () => {
for (const file of files) {
const fileName = path.basename(file);
const resolved = resolveFilePath(file);
let fileSize = 0;
if (resolved) {
try {
fileSize = fs.statSync(resolved).size;
} catch {
// will be caught by sendFileToVLC
}
}
sendProgress({
type: 'start',
file: fileName,
index: completed,
total: totalFiles,
size: fileSize,
});
const result = await sendFileToVLC(file, vlcHost);
completed++;
sendProgress({
type: result.success ? 'done' : 'error',
file: fileName,
index: completed - 1,
total: totalFiles,
error: result.error,
});
}
sendProgress({ type: 'complete', total: totalFiles });
res.end();
};
processFiles();
});
app.listen(PORT, () => {
console.log(`VLC Sender running on http://0.0.0.0:${PORT}`);
console.log(`Volumes:`);
VOLUMES.forEach((v) => console.log(` ${v.label}${v.path}`));
});

468
src/public/index.html Normal file
View File

@@ -0,0 +1,468 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VLC Sender</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
padding: 16px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 {
font-size: 1.5rem;
margin-bottom: 16px;
color: #ff8c00;
display: flex;
align-items: center;
gap: 8px;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.ip-group {
display: flex;
align-items: center;
gap: 4px;
background: #16213e;
border-radius: 8px;
padding: 6px 12px;
border: 1px solid #333;
}
.ip-group span { color: #888; font-size: 0.9rem; }
.ip-group input {
width: 50px;
background: transparent;
border: none;
color: #fff;
font-size: 0.9rem;
outline: none;
text-align: center;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send { background: #ff8c00; color: #000; }
.btn-back { background: #333; color: #eee; }
.btn-select { background: #16213e; color: #eee; border: 1px solid #444; font-size: 0.8rem; padding: 6px 10px; }
.breadcrumb {
display: flex;
gap: 4px;
margin-bottom: 12px;
flex-wrap: wrap;
font-size: 0.85rem;
}
.breadcrumb a {
color: #ff8c00;
text-decoration: none;
cursor: pointer;
}
.breadcrumb a:hover { text-decoration: underline; }
.breadcrumb span { color: #666; }
.file-list { list-style: none; }
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #222;
cursor: pointer;
transition: background 0.15s;
}
.file-item:hover { background: #16213e; }
.file-item input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #ff8c00;
cursor: pointer;
flex-shrink: 0;
}
.file-icon { font-size: 1.2rem; flex-shrink: 0; }
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size { color: #888; font-size: 0.8rem; flex-shrink: 0; }
.progress-panel {
margin-top: 16px;
background: #16213e;
border-radius: 8px;
padding: 16px;
display: none;
}
.progress-panel.active { display: block; }
.progress-item {
margin-bottom: 10px;
}
.progress-item:last-child { margin-bottom: 0; }
.progress-label {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 4px;
}
.progress-bar {
height: 6px;
background: #333;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
width: 0%;
}
.progress-fill.sending { background: #ff8c00; }
.progress-fill.done { background: #4caf50; width: 100%; }
.progress-fill.error { background: #f44336; width: 100%; }
.status-msg {
margin-top: 8px;
font-size: 0.85rem;
color: #888;
}
.status-msg.success { color: #4caf50; }
.status-msg.error { color: #f44336; }
.empty {
text-align: center;
color: #666;
padding: 40px;
}
.selected-count {
font-size: 0.85rem;
color: #888;
margin-left: auto;
}
</style>
</head>
<body>
<div class="container">
<h1>VLC Sender</h1>
<div class="toolbar">
<div class="ip-group">
<span>192.168.1.</span>
<input type="text" id="ipInput" placeholder="___" maxlength="3" inputmode="numeric">
</div>
<button class="btn btn-send" id="sendBtn" disabled>Отправить</button>
<button class="btn btn-select" id="selectAllBtn">Выбрать все</button>
<span class="selected-count" id="selectedCount"></span>
</div>
<div class="breadcrumb" id="breadcrumb"></div>
<ul class="file-list" id="fileList"></ul>
<div class="progress-panel" id="progressPanel"></div>
</div>
<script>
(function() {
const fileList = document.getElementById('fileList');
const breadcrumb = document.getElementById('breadcrumb');
const ipInput = document.getElementById('ipInput');
const sendBtn = document.getElementById('sendBtn');
const selectAllBtn = document.getElementById('selectAllBtn');
const selectedCount = document.getElementById('selectedCount');
const progressPanel = document.getElementById('progressPanel');
let currentPath = '';
let selectedFiles = new Set();
let currentFiles = [];
let sending = false;
// Restore last IP
const savedIp = localStorage.getItem('vlc-ip');
if (savedIp) ipInput.value = savedIp;
ipInput.addEventListener('input', () => {
localStorage.setItem('vlc-ip', ipInput.value);
updateSendBtn();
});
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function getFileIcon(name, isDir) {
if (isDir) return '\u{1F4C1}';
const ext = name.split('.').pop().toLowerCase();
const video = ['mkv', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'ts', 'm4v'];
const audio = ['mp3', 'flac', 'aac', 'ogg', 'wav', 'wma', 'm4a'];
const sub = ['srt', 'ass', 'ssa', 'sub', 'vtt'];
if (video.includes(ext)) return '\u{1F3AC}';
if (audio.includes(ext)) return '\u{1F3B5}';
if (sub.includes(ext)) return '\u{1F4DD}';
return '\u{1F4C4}';
}
function updateSendBtn() {
const ip = ipInput.value.trim();
const ipNum = parseInt(ip, 10);
const validIp = ip && !isNaN(ipNum) && ipNum >= 1 && ipNum <= 254;
sendBtn.disabled = sending || selectedFiles.size === 0 || !validIp;
}
function updateSelectedCount() {
if (selectedFiles.size > 0) {
selectedCount.textContent = 'Выбрано: ' + selectedFiles.size;
} else {
selectedCount.textContent = '';
}
updateSendBtn();
}
function renderBreadcrumb() {
const parts = currentPath ? currentPath.split('/') : [];
let html = '<a data-path="">root</a>';
let accumulated = '';
for (const part of parts) {
accumulated += (accumulated ? '/' : '') + part;
html += '<span>/</span><a data-path="' + accumulated + '">' + part + '</a>';
}
breadcrumb.innerHTML = html;
breadcrumb.querySelectorAll('a').forEach(a => {
a.addEventListener('click', () => {
navigateTo(a.dataset.path);
});
});
}
async function loadFiles(dirPath) {
try {
const res = await fetch('/api/files?path=' + encodeURIComponent(dirPath));
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'HTTP ' + res.status);
}
return { files: await res.json(), error: null };
} catch (e) {
console.error(e);
return { files: [], error: e.message };
}
}
async function navigateTo(dirPath) {
currentPath = dirPath;
selectedFiles.clear();
updateSelectedCount();
renderBreadcrumb();
const { files, error } = await loadFiles(dirPath);
currentFiles = files;
if (error) {
fileList.innerHTML = '<div class="empty" style="color:#f44336">Ошибка: ' + error + '</div>';
} else {
renderFiles(files);
}
}
function renderFiles(files) {
if (!files.length) {
fileList.innerHTML = '<div class="empty">Пусто</div>';
return;
}
fileList.innerHTML = '';
for (const f of files) {
const li = document.createElement('li');
li.className = 'file-item';
const filePath = currentPath ? currentPath + '/' + f.name : f.name;
if (f.isDir) {
li.innerHTML =
'<span class="file-icon">' + getFileIcon(f.name, true) + '</span>' +
'<span class="file-name">' + f.name + '</span>' +
'<span class="file-size">' + formatSize(f.size) + '</span>';
li.addEventListener('click', () => navigateTo(filePath));
} else {
const checked = selectedFiles.has(filePath) ? 'checked' : '';
li.innerHTML =
'<input type="checkbox" data-path="' + filePath + '" ' + checked + '>' +
'<span class="file-icon">' + getFileIcon(f.name, false) + '</span>' +
'<span class="file-name">' + f.name + '</span>' +
'<span class="file-size">' + formatSize(f.size) + '</span>';
const cb = li.querySelector('input[type="checkbox"]');
cb.addEventListener('change', (e) => {
e.stopPropagation();
if (cb.checked) {
selectedFiles.add(filePath);
} else {
selectedFiles.delete(filePath);
}
updateSelectedCount();
});
li.addEventListener('click', (e) => {
if (e.target === cb) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change'));
});
}
fileList.appendChild(li);
}
}
selectAllBtn.addEventListener('click', () => {
const nonDirFiles = currentFiles.filter(f => !f.isDir);
const allSelected = nonDirFiles.length > 0 && nonDirFiles.every(f => {
const p = currentPath ? currentPath + '/' + f.name : f.name;
return selectedFiles.has(p);
});
if (allSelected) {
// Deselect all
for (const f of nonDirFiles) {
const p = currentPath ? currentPath + '/' + f.name : f.name;
selectedFiles.delete(p);
}
} else {
// Select all
for (const f of nonDirFiles) {
const p = currentPath ? currentPath + '/' + f.name : f.name;
selectedFiles.add(p);
}
}
renderFiles(currentFiles);
updateSelectedCount();
});
sendBtn.addEventListener('click', () => {
if (sending || selectedFiles.size === 0) return;
const ip = ipInput.value.trim();
if (!ip) return;
sending = true;
updateSendBtn();
sendBtn.textContent = 'Отправка...';
const files = Array.from(selectedFiles);
progressPanel.classList.add('active');
progressPanel.innerHTML = files.map((f, i) => {
const name = f.split('/').pop();
return '<div class="progress-item" id="prog-' + i + '">' +
'<div class="progress-label"><span>' + name + '</span><span class="prog-status">Ожидание</span></div>' +
'<div class="progress-bar"><div class="progress-fill" id="fill-' + i + '"></div></div>' +
'</div>';
}).join('');
const body = JSON.stringify({ ip, files });
fetch('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function read() {
reader.read().then(({ done, value }) => {
if (done) {
sending = false;
sendBtn.textContent = 'Отправить';
updateSendBtn();
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
handleProgress(data);
} catch {}
}
read();
});
}
read();
}).catch(err => {
sending = false;
sendBtn.textContent = 'Отправить';
updateSendBtn();
const statusDiv = document.createElement('div');
statusDiv.className = 'status-msg error';
statusDiv.textContent = 'Ошибка соединения: ' + err.message;
progressPanel.appendChild(statusDiv);
});
});
function handleProgress(data) {
const { type, file, index, total, error } = data;
if (type === 'start') {
const item = document.getElementById('prog-' + index);
if (item) {
item.querySelector('.prog-status').textContent = 'Отправка...';
const fill = document.getElementById('fill-' + index);
fill.className = 'progress-fill sending';
fill.style.width = '50%';
}
} else if (type === 'done') {
const item = document.getElementById('prog-' + index);
if (item) {
item.querySelector('.prog-status').textContent = 'Готово';
const fill = document.getElementById('fill-' + index);
fill.className = 'progress-fill done';
}
} else if (type === 'error') {
const item = document.getElementById('prog-' + index);
if (item) {
item.querySelector('.prog-status').textContent = 'Ошибка';
const fill = document.getElementById('fill-' + index);
fill.className = 'progress-fill error';
}
const errDiv = document.createElement('div');
errDiv.className = 'status-msg error';
errDiv.textContent = file + ': ' + (error || 'Unknown error');
progressPanel.appendChild(errDiv);
} else if (type === 'complete') {
const statusDiv = document.createElement('div');
statusDiv.className = 'status-msg success';
statusDiv.textContent = 'Отправка завершена (' + total + ' файлов)';
progressPanel.appendChild(statusDiv);
}
}
// Initial load
navigateTo('');
})();
</script>
</body>
</html>