Видео проигрываеться

This commit is contained in:
2026-05-07 11:25:44 +03:00
parent 576e2b68fc
commit 6ec569f6e6
5 changed files with 251 additions and 157 deletions

View File

@@ -15,6 +15,7 @@ pub struct AccessConfig {
pub smb_url: Option<String>, pub smb_url: Option<String>,
pub smb_user: Option<String>, pub smb_user: Option<String>,
pub smb_pass: Option<String>, pub smb_pass: Option<String>,
pub smb_domain: Option<String>,
pub synology_path: Option<String>, pub synology_path: Option<String>,
} }
@@ -45,6 +46,7 @@ impl AccessConfig {
smb_url, smb_url,
smb_user: env::var(format!("{}_SMB_USER", prefix)).ok(), smb_user: env::var(format!("{}_SMB_USER", prefix)).ok(),
smb_pass: env::var(format!("{}_SMB_PASS", prefix)).ok(), smb_pass: env::var(format!("{}_SMB_PASS", prefix)).ok(),
smb_domain: env::var(format!("{}_SMB_DOMAIN", prefix)).ok(),
synology_path, synology_path,
}) })
} }

View File

@@ -71,7 +71,11 @@ impl FileAccess for LocalFileAccess {
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> { async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
let path = self.full_path(filename); let path = self.full_path(filename);
tokio::fs::read(&path).await.map_err(Into::into) log::debug!("LocalFileAccess reading file: {}", path);
tokio::fs::read(&path).await.map_err(|e| {
log::error!("Failed to read local file {}: {}", path, e);
anyhow::anyhow!(e)
})
} }
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> { async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
@@ -92,6 +96,7 @@ pub struct SmbAccessConfig {
pub url: String, pub url: String,
pub user: Option<String>, pub user: Option<String>,
pub password: Option<String>, pub password: Option<String>,
pub domain: Option<String>,
} }
pub struct SmbFileAccess { pub struct SmbFileAccess {
@@ -107,21 +112,29 @@ impl SmbFileAccess {
let base_unc = smb_url_to_unc(&self.config.url)?; let base_unc = smb_url_to_unc(&self.config.url)?;
let client = Client::new(ClientConfig::default()); let client = Client::new(ClientConfig::default());
if let (Some(user), Some(pass)) = (&self.config.user, &self.config.password) { let (user, pass) = match (&self.config.user, &self.config.password) {
client.share_connect(&base_unc, user, pass.clone()).await?; (Some(u), Some(p)) => (u.clone(), p.clone()),
} else { _ => {
client.share_connect(&base_unc, "", "".to_string()).await?; client.share_connect(&base_unc, "", "".to_string()).await?;
} return Ok((client, base_unc));
}
};
// Если задан домен, добавляем его к имени пользователя: DOMAIN\username
let full_user = match &self.config.domain {
Some(d) if !d.is_empty() => format!("{}\\{}", d, user),
_ => user,
};
client.share_connect(&base_unc, &full_user, pass).await?;
Ok((client, base_unc)) Ok((client, base_unc))
} }
fn full_unc_path(&self, base: &UncPath, filename: &str) -> UncPath { /// Построить полный UNC путь к файлу, добавляя filename к базовому пути (который уже включает подпапки)
let segments: Vec<&str> = filename.split('/').filter(|s| !s.is_empty()).collect(); fn make_file_unc(base: &UncPath, filename: &str) -> Result<UncPath> {
let mut path = base.clone(); let base_str = base.to_string(); // \\server\share\path
for seg in segments { let full = format!("{}\\{}", base_str.trim_end_matches('\\'), filename);
path = path.with_path(seg); UncPath::from_str(&full).map_err(|e| anyhow!("Invalid file UNC: {}", e))
}
path
} }
} }
@@ -135,11 +148,15 @@ impl FileAccess for SmbFileAccess {
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> { async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
let (client, base_unc) = self.connect().await?; let (client, base_unc) = self.connect().await?;
let file_path = self.full_unc_path(&base_unc, filename); let file_path = Self::make_file_unc(&base_unc, filename)?;
log::info!("SMB reading file: {:?}", file_path);
let args = let args =
FileCreateArgs::make_open_existing(FileAccessMask::new().with_generic_read(true)); FileCreateArgs::make_open_existing(FileAccessMask::new().with_generic_read(true));
let resource = client.create_file(&file_path, &args).await?; let resource = client.create_file(&file_path, &args).await.map_err(|e| {
log::error!("Failed to open SMB file {:?}: {}", file_path, e);
anyhow::anyhow!("Failed to open SMB file: {}", e)
})?;
let file = resource.unwrap_file(); let file = resource.unwrap_file();
let file_size = file.get_len().await? as usize; let file_size = file.get_len().await? as usize;
@@ -153,11 +170,15 @@ impl FileAccess for SmbFileAccess {
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> { async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
let (client, base_unc) = self.connect().await?; let (client, base_unc) = self.connect().await?;
let file_path = self.full_unc_path(&base_unc, filename); let file_path = Self::make_file_unc(&base_unc, filename)?;
log::info!("SMB writing file: {:?}", file_path);
let args = let args =
FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default()); FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default());
let resource = client.create_file(&file_path, &args).await?; let resource = client.create_file(&file_path, &args).await.map_err(|e| {
log::error!("Failed to create SMB file {:?}: {}", file_path, e);
anyhow::anyhow!("Failed to create SMB file: {}", e)
})?;
let remote_file = resource.unwrap_file(); let remote_file = resource.unwrap_file();
remote_file.write_at(data, 0).await?; remote_file.write_at(data, 0).await?;

View File

@@ -28,102 +28,86 @@
</div> </div>
</div> </div>
<!-- Tabs --> <!-- Action Bar -->
<div class="tabs"> <div class="action-bar">
<button class="tab-btn active" data-tab="jobs">Задания</button> <button class="btn btn-primary" onclick="generateJobs()">
<button class="tab-btn" data-tab="videos">Видео</button> <i class="fas fa-play"></i> Generate New Jobs
</div> </button>
<button class="btn btn-warning" onclick="stopAllJobs()">
<!-- Tab: Задания (оригинальный интерфейс полностью сохранён) --> <i class="fas fa-stop"></i> Stop All Jobs
<div id="tab-jobs" class="tab-content active"> </button>
<div class="action-bar"> <button class="btn btn-danger" onclick="cleanupJobs()">
<button class="btn btn-primary" onclick="generateJobs()"> <i class="fas fa-trash-alt"></i> Cleanup Finished
<i class="fas fa-play"></i> Generate New Jobs </button>
</button> <button class="btn btn-outline" onclick="refreshJobs()">
<button class="btn btn-warning" onclick="stopAllJobs()"> <i class="fas fa-sync-alt"></i> Refresh
<i class="fas fa-stop"></i> Stop All Jobs </button>
</button> <div class="status-message" id="statusMessage">
<button class="btn btn-danger" onclick="cleanupJobs()"> <i class="fas fa-circle" style="color: var(--accent-success); font-size: 8px;"></i>
<i class="fas fa-trash-alt"></i> Cleanup Finished <span>Ready</span>
</button>
<button class="btn btn-outline" onclick="refreshJobs()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<div class="status-message" id="statusMessage">
<i class="fas fa-circle" style="color: var(--accent-success); font-size: 8px;"></i>
<span>Ready</span>
</div>
</div>
<div class="stats-grid" id="statsGrid"></div>
<div class="filter-bar">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" class="search-input" id="filterInput" placeholder="Filter by filename or UID..."
onkeyup="filterTable()">
</div>
<div class="auto-refresh-badge">
<i class="fas fa-clock"></i>
<span>Auto-refresh: 60s</span>
<span id="refreshCountdown">60</span>s
</div>
</div>
<div class="table-container">
<table id="jobsTable">
<thead>
<tr>
<th data-column="filename" onclick="sortTable('filename')">
Output File <i class="fas fa-sort"></i>
</th>
<th data-column="state" onclick="sortTable('state')" class="sorted-asc">
State <i class="fas fa-sort-up"></i>
</th>
<th data-column="created" onclick="sortTable('created')">
Created <i class="fas fa-sort"></i>
</th>
<th data-column="updated" onclick="sortTable('updated')">
Updated <i class="fas fa-sort"></i>
</th>
<th data-column="uid" onclick="sortTable('uid')">
UID <i class="fas fa-sort"></i>
</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
<!-- Tab: Видео --> <!-- Stats Grid -->
<div id="tab-videos" class="tab-content"> <div class="stats-grid" id="statsGrid"></div>
<div class="action-bar">
<button class="btn btn-primary" onclick="loadVideos()"> <!-- Filter Bar -->
<i class="fas fa-sync-alt"></i> Обновить список <div class="filter-bar">
</button> <div class="search-wrapper">
<div class="status-message" id="videosStatusMessage"> <i class="fas fa-search"></i>
<span>Видео не загружены</span> <input type="text" class="search-input" id="filterInput" placeholder="Filter by filename or UID..."
</div> onkeyup="filterTable()">
</div>
<div class="auto-refresh-badge">
<i class="fas fa-clock"></i>
<span>Auto-refresh: 60s</span>
<span id="refreshCountdown">60</span>s
</div> </div>
<div class="video-list" id="videoList"></div>
</div> </div>
<!-- Video Player Modal --> <!-- Table -->
<div id="playerModal" class="modal"> <div class="table-container">
<div class="modal-content"> <table id="jobsTable">
<span class="close" onclick="closePlayer()">&times;</span> <thead>
<video id="videoPlayer" controls style="width:100%; max-height:70vh;"></video> <tr>
</div> <th data-column="filename" onclick="sortTable('filename')">
Output File <i class="fas fa-sort"></i>
</th>
<th data-column="state" onclick="sortTable('state')" class="sorted-asc">
State <i class="fas fa-sort-up"></i>
</th>
<th data-column="created" onclick="sortTable('created')">
Created <i class="fas fa-sort"></i>
</th>
<th data-column="updated" onclick="sortTable('updated')">
Updated <i class="fas fa-sort"></i>
</th>
<th data-column="uid" onclick="sortTable('uid')">
UID <i class="fas fa-sort"></i>
</th>
<th>Preview</th>
<th>Move</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<tr>
<td colspan="7">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Video Player Modal -->
<div id="playerModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closePlayer()">&times;</span>
<video id="videoPlayer" controls style="width:100%; max-height:70vh;"></video>
</div> </div>
</div> </div>
@@ -228,19 +212,27 @@
function renderJobs(jobs) { function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody'); const tbody = document.getElementById('jobsTableBody');
if (jobs.length === 0) { if (jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><i class="fas fa-inbox"></i><p>No jobs found</p></div></td></tr>'; tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><i class="fas fa-inbox"></i><p>No jobs found</p></div></td></tr>';
return; return;
} }
tbody.innerHTML = jobs.map(job => { tbody.innerHTML = jobs.map(job => {
const stateClass = getStateClass(job.state); const stateClass = getStateClass(job.state);
const created = formatDateTime(job.created_at); const created = formatDateTime(job.created_at);
const updated = formatDateTime(job.updated_at); const updated = formatDateTime(job.updated_at);
const previewBtn = job.state === 'finished'
? `<button class="btn btn-outline btn-sm" onclick="playVideo('${escapeHtml(job.outfile_name)}')"><i class="fas fa-play"></i></button>`
: '';
const moveBtn = job.state === 'finished'
? `<button class="btn btn-primary btn-sm" onclick="moveVideo('${escapeHtml(job.outfile_name)}')"><i class="fas fa-share"></i></button>`
: '';
return `<tr> return `<tr>
<td><div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div></td> <td><div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div></td>
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td> <td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
<td class="datetime">${created}</td> <td class="datetime">${created}</td>
<td class="datetime">${updated}</td> <td class="datetime">${updated}</td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td> <td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
<td>${previewBtn}</td>
<td>${moveBtn}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
@@ -277,66 +269,98 @@
filteredJobs = allJobs.filter(job => job.outfile_name.toLowerCase().includes(filter) || job.uid.toLowerCase().includes(filter)); filteredJobs = allJobs.filter(job => job.outfile_name.toLowerCase().includes(filter) || job.uid.toLowerCase().includes(filter));
sortAndRender(); sortAndRender();
} }
async function generateJobs() { /* ... тот же код ... */ }
async function stopAllJobs() { /* ... тот же код ... */ }
async function cleanupJobs() { /* ... тот же код ... */ }
function setStatus(type, message) { /* ... тот же код ... */ }
function startAutoRefresh() { /* ... тот же код ... */ }
function stopAutoRefresh() { /* ... тот же код ... */ }
function resetCountdown() { /* ... тот же код ... */ }
// Скопируйте оригинальные реализации generateJobs, stopAllJobs, cleanupJobs, setStatus, startAutoRefresh, stopAutoRefresh, resetCountdown (они у вас уже были) // Оригинальные реализации generateJobs, stopAllJobs, cleanupJobs, setStatus, startAutoRefresh, stopAutoRefresh, resetCountdown
// Я не стал дублировать весь код, чтобы не загромождать ответ. Просто вставьте их из вашего предыдущего файла. async function generateJobs() {
setStatus('loading', 'Generating jobs...');
try {
const response = await fetch('/api/generate', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Job generation started');
setTimeout(() => refreshJobs(), 5000);
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
// ========== НОВЫЙ КОД: Вкладки ========== async function stopAllJobs() {
document.querySelectorAll('.tab-btn').forEach(btn => { if (!confirm('Are you sure you want to stop all active jobs?')) return;
btn.addEventListener('click', () => { setStatus('loading', 'Stopping all jobs...');
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); try {
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
btn.classList.add('active'); if (response.ok) {
document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); setStatus('success', 'All jobs stopped');
if (btn.dataset.tab === 'videos') loadVideos(); refreshJobs();
}); } else {
}); const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
async function cleanupJobs() {
setStatus('loading', 'Cleaning up finished jobs...');
try {
const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Cleanup completed');
await refreshJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
function setStatus(type, message) {
const el = document.getElementById('statusMessage');
const icons = {
loading: '<span class="spinner"></span>',
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>',
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>'
};
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
}
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = setInterval(() => { refreshJobs(); resetCountdown(); }, 60000);
countdownTimer = setInterval(() => {
countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) countdownValue = 60;
}, 1000);
}
function stopAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (countdownTimer) clearInterval(countdownTimer);
}
function resetCountdown() {
countdownValue = 60;
document.getElementById('refreshCountdown').textContent = countdownValue;
}
// ========== НОВЫЙ КОД: Видео ========== // ========== НОВЫЙ КОД: Видео ==========
async function loadVideos() {
try {
const response = await fetch('/api/videos');
if (!response.ok) throw new Error(await response.text());
const videos = await response.json();
renderVideoList(videos);
document.getElementById('videosStatusMessage').innerHTML = `<span>Найдено файлов: ${videos.length}</span>`;
} catch (e) {
document.getElementById('videosStatusMessage').innerHTML = `<span style="color:red;">Ошибка: ${e.message}</span>`;
}
}
function renderVideoList(videos) {
const container = document.getElementById('videoList');
if (videos.length === 0) {
container.innerHTML = '<p>Видеофайлы не найдены.</p>';
return;
}
container.innerHTML = videos.map(name => `
<div class="video-item">
<span class="video-name" title="${name}">${name}</span>
<button class="btn btn-outline btn-sm" onclick="playVideo('${name}')"><i class="fas fa-play"></i> Смотреть</button>
<button class="btn btn-primary btn-sm" onclick="moveVideo('${name}')"><i class="fas fa-share"></i> Перенести</button>
</div>
`).join('');
}
function playVideo(filename) { function playVideo(filename) {
const player = document.getElementById('videoPlayer'); const player = document.getElementById('videoPlayer');
player.src = '/api/videos/' + encodeURIComponent(filename); player.src = '/api/videos/' + encodeURIComponent(filename);
document.getElementById('playerModal').style.display = 'flex'; document.getElementById('playerModal').classList.add('active');
} }
function closePlayer() { function closePlayer() {
const player = document.getElementById('videoPlayer'); const player = document.getElementById('videoPlayer');
player.pause(); player.pause();
player.src = ''; player.src = '';
document.getElementById('playerModal').style.display = 'none'; document.getElementById('playerModal').classList.remove('active');
} }
async function moveVideo(filename) { async function moveVideo(filename) {
@@ -345,7 +369,7 @@
const response = await fetch('/api/videos/' + encodeURIComponent(filename) + '/move?remove=true', { method: 'POST' }); const response = await fetch('/api/videos/' + encodeURIComponent(filename) + '/move?remove=true', { method: 'POST' });
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
alert('Файл успешно перемещён.'); alert('Файл успешно перемещён.');
loadVideos(); refreshJobs(); // обновим список, возможно, файл исчезнет
} catch (e) { } catch (e) {
alert('Ошибка перемещения: ' + e.message); alert('Ошибка перемещения: ' + e.message);
} }

View File

@@ -553,3 +553,47 @@ tr:hover {
margin-left: 0; margin-left: 0;
} }
} }
/* Модальное окно плеера */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
max-width: 90vw;
box-shadow: var(--shadow-lg);
position: relative;
}
.modal .close {
position: absolute;
top: 8px;
right: 16px;
font-size: 28px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
}
/* Кнопки в таблице (уменьшенный размер) */
.btn-sm {
padding: 4px 8px;
font-size: 12px;
gap: 4px;
}

View File

@@ -144,10 +144,12 @@ async fn create_file_access(
let url = access_config.smb_url.as_ref().unwrap().clone(); let url = access_config.smb_url.as_ref().unwrap().clone();
let user = access_config.smb_user.clone(); let user = access_config.smb_user.clone();
let password = access_config.smb_pass.clone(); let password = access_config.smb_pass.clone();
let domain = access_config.smb_domain.clone(); // теперь читаем домен
let smb_config = SmbAccessConfig { let smb_config = SmbAccessConfig {
url, url,
user, user,
password, password,
domain,
}; };
Ok(Arc::new(SmbFileAccess::new(smb_config))) Ok(Arc::new(SmbFileAccess::new(smb_config)))
} }
@@ -305,6 +307,7 @@ async fn stream_video(
State(state): State<AppState>, State(state): State<AppState>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
log::info!("Stream video requested: {}", filename);
let file_data = state let file_data = state
.source_access .source_access
.read_file(&filename) .read_file(&filename)