scan media folder

This commit is contained in:
2026-02-06 09:06:10 -03:00
parent 68622bd6b1
commit 2e6ed4e37a
4 changed files with 270 additions and 126 deletions

View File

@@ -30,10 +30,19 @@ def create_asset(data: AssetCreate):
if not path.exists(): if not path.exists():
raise HTTPException(status_code=400, detail="File not found") raise HTTPException(status_code=400, detail="File not found")
# Store path relative to media root
import os
media_root = Path(os.environ.get("MEDIA_ROOT", "/app/media"))
try:
rel_path = str(path.relative_to(media_root))
except ValueError:
rel_path = path.name
# Create asset # Create asset
asset = MediaAsset.objects.create( asset = MediaAsset.objects.create(
filename=data.filename or path.name, filename=data.filename or path.name,
file_path=str(path.absolute()), file_path=rel_path,
file_size=path.stat().st_size, file_size=path.stat().st_size,
) )
@@ -88,3 +97,68 @@ def update_asset(asset_id: UUID, data: AssetUpdate, asset=Depends(get_asset)):
def delete_asset(asset_id: UUID, asset=Depends(get_asset)): def delete_asset(asset_id: UUID, asset=Depends(get_asset)):
"""Delete an asset.""" """Delete an asset."""
asset.delete() asset.delete()
@router.post("/scan", response_model=dict)
def scan_media_folder():
"""
Scan the media folder for new video/audio files and register them as assets.
Returns a summary of files found and registered.
"""
import os
from pathlib import Path
from mpr.media_assets.models import MediaAsset
# Get media root from environment
media_root = os.environ.get("MEDIA_ROOT", "/app/media")
media_path = Path(media_root)
if not media_path.exists():
raise HTTPException(
status_code=500, detail=f"Media folder not found: {media_root}"
)
# Supported video/audio extensions
video_exts = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".flv", ".wmv", ".m4v"}
audio_exts = {".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"}
supported_exts = video_exts | audio_exts
# Get existing filenames to avoid duplicates
existing_filenames = set(MediaAsset.objects.values_list("filename", flat=True))
# Scan for media files
found_files = []
registered_files = []
skipped_files = []
for file_path in media_path.rglob("*"):
if file_path.is_file() and file_path.suffix.lower() in supported_exts:
found_files.append(str(file_path))
# Skip if already registered
if file_path.name in existing_filenames:
skipped_files.append(file_path.name)
continue
# Register new asset with path relative to media root
rel_path = str(file_path.relative_to(media_path))
try:
asset = MediaAsset.objects.create(
filename=file_path.name,
file_path=rel_path,
file_size=file_path.stat().st_size,
)
registered_files.append(file_path.name)
# TODO: Queue probe task to extract metadata
except Exception as e:
print(f"Error registering {file_path.name}: {e}")
return {
"found": len(found_files),
"registered": len(registered_files),
"skipped": len(skipped_files),
"files": registered_files,
}

View File

@@ -1,188 +1,217 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family:
background: #1a1a1a; -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #e0e0e0; background: #1a1a1a;
color: #e0e0e0;
} }
.app { .app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
background: #252525; background: #252525;
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
} }
.header h1 { .header h1 {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
.status { .status {
font-size: 0.875rem; font-size: 0.875rem;
color: #888; color: #888;
} }
.layout { .layout {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
} }
.sidebar { .sidebar {
width: 280px; width: 280px;
background: #202020; background: #202020;
border-right: 1px solid #333; border-right: 1px solid #333;
overflow-y: auto; overflow-y: auto;
} }
.sidebar h2 { .sidebar-header {
padding: 1rem; padding: 1rem;
font-size: 0.875rem; display: flex;
text-transform: uppercase; justify-content: space-between;
letter-spacing: 0.05em; align-items: center;
color: #888; gap: 0.5rem;
}
.sidebar-header h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #888;
}
.scan-button {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.scan-button:hover:not(:disabled) {
background: #2563eb;
}
.scan-button:disabled {
background: #4b5563;
cursor: not-allowed;
opacity: 0.6;
} }
.asset-list { .asset-list {
list-style: none; list-style: none;
} }
.asset-list li { .asset-list li {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #2a2a2a; border-bottom: 1px solid #2a2a2a;
} }
.asset-list li:hover { .asset-list li:hover {
background: #2a2a2a; background: #2a2a2a;
} }
.asset-list li.selected { .asset-list li.selected {
background: #333; background: #333;
} }
.filename { .filename {
font-size: 0.875rem; font-size: 0.875rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.status-badge { .status-badge {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 4px; border-radius: 4px;
text-transform: uppercase; text-transform: uppercase;
} }
.status-badge.pending { .status-badge.pending {
background: #f59e0b; background: #f59e0b;
color: #000; color: #000;
} }
.status-badge.ready { .status-badge.ready {
background: #10b981; background: #10b981;
color: #000; color: #000;
} }
.status-badge.error { .status-badge.error {
background: #ef4444; background: #ef4444;
color: #fff; color: #fff;
} }
.main { .main {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.empty, .empty,
.loading, .loading,
.error { .error {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
color: #666; color: #666;
} }
.error { .error {
color: #ef4444; color: #ef4444;
} }
.editor { .editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
} }
.video-container { .video-container {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #000; background: #000;
min-height: 0; min-height: 0;
} }
.video-container video { .video-container video {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
.timeline-container { .timeline-container {
height: 120px; height: 120px;
background: #252525; background: #252525;
border-top: 1px solid #333; border-top: 1px solid #333;
} }
.timeline-placeholder { .timeline-placeholder {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
color: #666; color: #666;
} }
.info { .info {
padding: 1rem; padding: 1rem;
background: #202020; background: #202020;
border-top: 1px solid #333; border-top: 1px solid #333;
} }
.info h3 { .info h3 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 1rem; font-size: 1rem;
} }
.info dl { .info dl {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: 0.25rem 1rem; gap: 0.25rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
.info dt { .info dt {
color: #888; color: #888;
} }
.info dd { .info dd {
color: #e0e0e0; color: #e0e0e0;
} }

View File

@@ -1,14 +1,15 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from "react";
import { getAssets, getSystemStatus } from './api' import { getAssets, getSystemStatus, scanMediaFolder } from "./api";
import type { MediaAsset, SystemStatus } from './types' import type { MediaAsset, SystemStatus } from "./types";
import './App.css' import "./App.css";
function App() { function App() {
const [assets, setAssets] = useState<MediaAsset[]>([]) const [assets, setAssets] = useState<MediaAsset[]>([]);
const [status, setStatus] = useState<SystemStatus | null>(null) const [status, setStatus] = useState<SystemStatus | null>(null);
const [selectedAsset, setSelectedAsset] = useState<MediaAsset | null>(null) const [selectedAsset, setSelectedAsset] = useState<MediaAsset | null>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@@ -16,24 +17,47 @@ function App() {
const [assetsData, statusData] = await Promise.all([ const [assetsData, statusData] = await Promise.all([
getAssets(), getAssets(),
getSystemStatus(), getSystemStatus(),
]) ]);
setAssets(assetsData) setAssets(
setStatus(statusData) assetsData.sort((a, b) => a.filename.localeCompare(b.filename)),
);
setStatus(statusData);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load') setError(e instanceof Error ? e.message : "Failed to load");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} }
load() load();
}, []) }, []);
async function handleScan() {
setScanning(true);
setError(null);
try {
const result = await scanMediaFolder();
alert(
`Scan complete!\nFound: ${result.found}\nRegistered: ${result.registered}\nSkipped: ${result.skipped}`,
);
// Reload assets
const assetsData = await getAssets();
setAssets(
assetsData.sort((a, b) => a.filename.localeCompare(b.filename)),
);
} catch (e) {
setError(e instanceof Error ? e.message : "Scan failed");
} finally {
setScanning(false);
}
}
if (loading) { if (loading) {
return <div className="loading">Loading...</div> return <div className="loading">Loading...</div>;
} }
if (error) { if (error) {
return <div className="error">Error: {error}</div> return <div className="error">Error: {error}</div>;
} }
return ( return (
@@ -49,18 +73,25 @@ function App() {
<div className="layout"> <div className="layout">
<aside className="sidebar"> <aside className="sidebar">
<h2>Assets</h2> <div className="sidebar-header">
<h2>Assets</h2>
<button
onClick={handleScan}
disabled={scanning}
className="scan-button"
>
{scanning ? "Scanning..." : "Scan Folder"}
</button>
</div>
<ul className="asset-list"> <ul className="asset-list">
{assets.map((asset) => ( {assets.map((asset) => (
<li <li
key={asset.id} key={asset.id}
className={selectedAsset?.id === asset.id ? 'selected' : ''} className={selectedAsset?.id === asset.id ? "selected" : ""}
onClick={() => setSelectedAsset(asset)} onClick={() => setSelectedAsset(asset)}
title={asset.filename}
> >
<span className="filename">{asset.filename}</span> <span className="filename">{asset.filename}</span>
<span className={`status-badge ${asset.status}`}>
{asset.status}
</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -70,10 +101,7 @@ function App() {
{selectedAsset ? ( {selectedAsset ? (
<div className="editor"> <div className="editor">
<div className="video-container"> <div className="video-container">
<video <video controls src={`/media/${selectedAsset.file_path}`} />
controls
src={`/media/${selectedAsset.file_path}`}
/>
</div> </div>
<div className="timeline-container"> <div className="timeline-container">
{/* Timeline component will go here */} {/* Timeline component will go here */}
@@ -87,7 +115,9 @@ function App() {
<dt>Duration</dt> <dt>Duration</dt>
<dd>{selectedAsset.duration?.toFixed(2)}s</dd> <dd>{selectedAsset.duration?.toFixed(2)}s</dd>
<dt>Resolution</dt> <dt>Resolution</dt>
<dd>{selectedAsset.width}x{selectedAsset.height}</dd> <dd>
{selectedAsset.width}x{selectedAsset.height}
</dd>
<dt>Video</dt> <dt>Video</dt>
<dd>{selectedAsset.video_codec}</dd> <dd>{selectedAsset.video_codec}</dd>
<dt>Audio</dt> <dt>Audio</dt>
@@ -101,7 +131,7 @@ function App() {
</main> </main>
</div> </div>
</div> </div>
) );
} }
export default App export default App;

View File

@@ -38,6 +38,17 @@ export async function getAsset(id: string): Promise<MediaAsset> {
return request(`/assets/${id}`); return request(`/assets/${id}`);
} }
export async function scanMediaFolder(): Promise<{
found: number;
registered: number;
skipped: number;
files: string[];
}> {
return request("/assets/scan", {
method: "POST",
});
}
// Presets // Presets
export async function getPresets(): Promise<TranscodePreset[]> { export async function getPresets(): Promise<TranscodePreset[]> {
return request("/presets/"); return request("/presets/");