diff --git a/api/routes/assets.py b/api/routes/assets.py index 0e37477..e1a51ef 100644 --- a/api/routes/assets.py +++ b/api/routes/assets.py @@ -30,10 +30,19 @@ def create_asset(data: AssetCreate): if not path.exists(): 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 asset = MediaAsset.objects.create( filename=data.filename or path.name, - file_path=str(path.absolute()), + file_path=rel_path, 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)): """Delete an asset.""" 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, + } diff --git a/ui/timeline/src/App.css b/ui/timeline/src/App.css index 754af87..1d413d7 100644 --- a/ui/timeline/src/App.css +++ b/ui/timeline/src/App.css @@ -1,188 +1,217 @@ * { - box-sizing: border-box; - margin: 0; - padding: 0; + box-sizing: border-box; + margin: 0; + padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #1a1a1a; - color: #e0e0e0; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1a1a1a; + color: #e0e0e0; } .app { - display: flex; - flex-direction: column; - height: 100vh; + display: flex; + flex-direction: column; + height: 100vh; } .header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem; - background: #252525; - border-bottom: 1px solid #333; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: #252525; + border-bottom: 1px solid #333; } .header h1 { - font-size: 1.25rem; - font-weight: 600; + font-size: 1.25rem; + font-weight: 600; } .status { - font-size: 0.875rem; - color: #888; + font-size: 0.875rem; + color: #888; } .layout { - display: flex; - flex: 1; - overflow: hidden; + display: flex; + flex: 1; + overflow: hidden; } .sidebar { - width: 280px; - background: #202020; - border-right: 1px solid #333; - overflow-y: auto; + width: 280px; + background: #202020; + border-right: 1px solid #333; + overflow-y: auto; } -.sidebar h2 { - padding: 1rem; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #888; +.sidebar-header { + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + 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 { - list-style: none; + list-style: none; } .asset-list li { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - cursor: pointer; - border-bottom: 1px solid #2a2a2a; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 1px solid #2a2a2a; } .asset-list li:hover { - background: #2a2a2a; + background: #2a2a2a; } .asset-list li.selected { - background: #333; + background: #333; } .filename { - font-size: 0.875rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .status-badge { - font-size: 0.75rem; - padding: 0.125rem 0.5rem; - border-radius: 4px; - text-transform: uppercase; + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + border-radius: 4px; + text-transform: uppercase; } .status-badge.pending { - background: #f59e0b; - color: #000; + background: #f59e0b; + color: #000; } .status-badge.ready { - background: #10b981; - color: #000; + background: #10b981; + color: #000; } .status-badge.error { - background: #ef4444; - color: #fff; + background: #ef4444; + color: #fff; } .main { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; } .empty, .loading, .error { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #666; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; } .error { - color: #ef4444; + color: #ef4444; } .editor { - display: flex; - flex-direction: column; - height: 100%; + display: flex; + flex-direction: column; + height: 100%; } .video-container { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - background: #000; - min-height: 0; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #000; + min-height: 0; } .video-container video { - max-width: 100%; - max-height: 100%; + max-width: 100%; + max-height: 100%; } .timeline-container { - height: 120px; - background: #252525; - border-top: 1px solid #333; + height: 120px; + background: #252525; + border-top: 1px solid #333; } .timeline-placeholder { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #666; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; } .info { - padding: 1rem; - background: #202020; - border-top: 1px solid #333; + padding: 1rem; + background: #202020; + border-top: 1px solid #333; } .info h3 { - margin-bottom: 0.5rem; - font-size: 1rem; + margin-bottom: 0.5rem; + font-size: 1rem; } .info dl { - display: grid; - grid-template-columns: auto 1fr; - gap: 0.25rem 1rem; - font-size: 0.875rem; + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + font-size: 0.875rem; } .info dt { - color: #888; + color: #888; } .info dd { - color: #e0e0e0; + color: #e0e0e0; } diff --git a/ui/timeline/src/App.tsx b/ui/timeline/src/App.tsx index ebf9944..52a94b5 100644 --- a/ui/timeline/src/App.tsx +++ b/ui/timeline/src/App.tsx @@ -1,14 +1,15 @@ -import { useState, useEffect } from 'react' -import { getAssets, getSystemStatus } from './api' -import type { MediaAsset, SystemStatus } from './types' -import './App.css' +import { useState, useEffect } from "react"; +import { getAssets, getSystemStatus, scanMediaFolder } from "./api"; +import type { MediaAsset, SystemStatus } from "./types"; +import "./App.css"; function App() { - const [assets, setAssets] = useState([]) - const [status, setStatus] = useState(null) - const [selectedAsset, setSelectedAsset] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [assets, setAssets] = useState([]); + const [status, setStatus] = useState(null); + const [selectedAsset, setSelectedAsset] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [scanning, setScanning] = useState(false); useEffect(() => { async function load() { @@ -16,24 +17,47 @@ function App() { const [assetsData, statusData] = await Promise.all([ getAssets(), getSystemStatus(), - ]) - setAssets(assetsData) - setStatus(statusData) + ]); + setAssets( + assetsData.sort((a, b) => a.filename.localeCompare(b.filename)), + ); + setStatus(statusData); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load') + setError(e instanceof Error ? e.message : "Failed to load"); } 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) { - return
Loading...
+ return
Loading...
; } if (error) { - return
Error: {error}
+ return
Error: {error}
; } return ( @@ -49,18 +73,25 @@ function App() {