scan media folder
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/");
|
||||||
|
|||||||
Reference in New Issue
Block a user