From f3d8f51ad302937a6e0270a6af5065e0e880b31e Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 2 May 2026 03:39:34 +0300 Subject: [PATCH 01/16] feat: add video preview generation --- API.md | 35 +++ README.md | 4 +- backend/app/config.py | 2 + backend/app/db.py | 13 +- backend/app/embed_preview.py | 13 +- backend/app/main.py | 14 +- backend/app/postprocessing.py | 76 +++++-- backend/app/routers/tokens.py | 40 +++- backend/app/templates/share_preview.html | 15 +- backend/app/utils.py | 224 ++++++++++++++++++-- backend/tests/conftest.py | 2 +- backend/tests/test_admin.py | 3 + backend/tests/test_cleanup.py | 29 ++- backend/tests/test_download_restrictions.py | 19 ++ backend/tests/test_faststart.py | 20 ++ backend/tests/test_postprocessing.py | 25 ++- backend/tests/test_share_view.py | 134 ++++++++++++ backend/tests/test_stream_file.py | 50 +++++ 18 files changed, 650 insertions(+), 68 deletions(-) diff --git a/API.md b/API.md index 0b536f5..09a65ba 100644 --- a/API.md +++ b/API.md @@ -25,6 +25,7 @@ This document describes the FBC Uploader REST API endpoints. All endpoints retur - [GET /api/tokens/{token\_value}/uploads](#get-apitokenstoken_valueuploads) - [GET /api/tokens/{download\_token}/uploads/{upload\_id}](#get-apitokensdownload_tokenuploadsupload_id) - [GET /api/tokens/{download\_token}/uploads/{upload\_id}/stream](#get-apitokensdownload_tokenuploadsupload_idstream) + - [GET /api/tokens/{download\_token}/uploads/{upload\_id}/preview.mp4](#get-apitokensdownload_tokenuploadsupload_idpreviewmp4) - [GET /api/tokens/{download\_token}/uploads/{upload\_id}/thumbnail](#get-apitokensdownload_tokenuploadsupload_idthumbnail) - [GET /api/tokens/{download\_token}/uploads/{upload\_id}/download](#get-apitokensdownload_tokenuploadsupload_iddownload) - [POST /api/uploads/initiate](#post-apiuploadsinitiate) @@ -435,6 +436,8 @@ Get metadata information about a completed upload. ### GET /api/tokens/{download_token}/uploads/{upload_id}/stream +`HEAD` is also supported. + Stream a completed file inline for browser playback. **Authentication:** Required (Admin, or public if `FBC_ALLOW_PUBLIC_DOWNLOADS=1`) @@ -459,8 +462,38 @@ Returns the file with headers: --- +### GET /api/tokens/{download_token}/uploads/{upload_id}/preview.mp4 + +`HEAD` is also supported. + +Return a short MP4 preview clip for bot embeds when one has been generated. + +**Authentication:** Required (Admin, or public if `FBC_ALLOW_PUBLIC_DOWNLOADS=1`) + +**Path Parameters:** +- `download_token` (string): The download token +- `upload_id` (integer): The upload record ID + +**Response (200):** +Returns the file with headers: +- `Content-Type`: `video/mp4` +- `Content-Disposition`: `inline; filename="..."` + +**Error Responses:** +- `404 Not Found` - Download token, upload, or preview not found +- `409 Conflict` - Upload not yet completed + +**Notes:** +- Bot embed metadata may use this endpoint instead of the full stream for large videos that meet the preview size threshold. +- Setting `FBC_EMBED_PREVIEW_MIN_SIZE_BYTES=0` disables preview sidecars and keeps embed metadata on the original `/stream` URL. +- If no preview sidecar exists, embed metadata falls back to the original `/stream` URL. + +--- + ### GET /api/tokens/{download_token}/uploads/{upload_id}/thumbnail +`HEAD` is also supported. + Return a preview image for a completed upload. **Authentication:** Required (Admin, or public if `FBC_ALLOW_PUBLIC_DOWNLOADS=1`) @@ -487,6 +520,8 @@ Returns an image with headers: ### GET /api/tokens/{download_token}/uploads/{upload_id}/download +`HEAD` is also supported. + Download a completed file. **Authentication:** Required (Admin, or public if `FBC_ALLOW_PUBLIC_DOWNLOADS=1`) diff --git a/README.md b/README.md index f813af1..d12bf44 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ All configuration is done via environment variables prefixed with `FBC_`: | `FBC_MAX_CHUNK_BYTES` | `94371840` | Maximum TUS chunk size. Default to (90MB) | | `FBC_MAX_REMUX_BYTES` | `5368709120` | Maximum file size eligible for copy-remux to MP4 during post-processing (5GB) | | `FBC_POSTPROCESSING_WORKERS` | `4` | Number of uploads processed concurrently in the background post-processing queue | +| `FBC_EMBED_PREVIEW_CLIP_SECONDS` | `10` | Length of generated bot preview clips in seconds (0 disables preview generation) | +| `FBC_EMBED_PREVIEW_MIN_SIZE_BYTES` | `204472320` | Only generate bot preview clips for videos at or above this size in bytes (195 MB); `0` disables the feature | | `FBC_ALLOW_PUBLIC_DOWNLOADS` | `false` | Allow public downloads without authentication | | `FBC_TRUST_PROXY_HEADERS` | `false` | Trust `X-Forwarded-*` headers, but only from proxies in `FBC_FORWARDED_ALLOW_IPS` | | `FBC_FORWARDED_ALLOW_IPS` | `127.0.0.1,::1` | Comma-separated trusted proxy IPs or CIDRs allowed to supply forwarded headers | @@ -76,7 +78,7 @@ When running behind a reverse proxy, `FBC_TRUST_PROXY_HEADERS=true` is not enoug If you leave `FBC_FORWARDED_ALLOW_IPS` at its default, only local loopback proxies are trusted. This protects against clients forging `X-Forwarded-For`, `X-Forwarded-Proto`, or `X-Forwarded-Host` when the app is exposed directly. -Uploaded multimedia files are post-processed after upload completion. Browser-safe `mp4` and `webm` files are kept as-is. Compatible non-MP4 video containers may be copy-remuxed into `mp4` for better playback compatibility without transcoding. Files larger than `FBC_MAX_REMUX_BYTES` skip remux and still complete normally. The background worker pool processes up to `FBC_POSTPROCESSING_WORKERS` uploads concurrently. +Uploaded multimedia files are post-processed after upload completion. Browser-safe `mp4` and `webm` files are kept as-is. Compatible non-MP4 video containers may be copy-remuxed into `mp4` for better playback compatibility without transcoding. Files larger than `FBC_MAX_REMUX_BYTES` skip remux and still complete normally. Large videos can also get short MP4 bot-preview sidecars for embeds, controlled by `FBC_EMBED_PREVIEW_CLIP_SECONDS` and `FBC_EMBED_PREVIEW_MIN_SIZE_BYTES`. Setting `FBC_EMBED_PREVIEW_MIN_SIZE_BYTES=0` disables bot preview sidecars entirely. The background worker pool processes up to `FBC_POSTPROCESSING_WORKERS` uploads concurrently. ## Dynamic Metadata Schema diff --git a/backend/app/config.py b/backend/app/config.py index b4ab840..e98c251 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,6 +25,8 @@ class Settings(BaseSettings): max_chunk_bytes: int = Field(90 * 1024 * 1024, validation_alias="FBC_MAX_CHUNK_BYTES") max_remux_bytes: int = Field(5 * 1024 * 1024 * 1024, validation_alias="FBC_MAX_REMUX_BYTES") postprocessing_workers: int = Field(4, ge=1, validation_alias="FBC_POSTPROCESSING_WORKERS") + embed_preview_clip_seconds: int = Field(30, ge=0, le=60, validation_alias="FBC_EMBED_PREVIEW_CLIP_SECONDS") + embed_preview_min_size_bytes: int = Field(195 * 1024 * 1024, ge=0, validation_alias="FBC_EMBED_PREVIEW_MIN_SIZE_BYTES") allow_public_downloads: bool = Field(False, validation_alias="FBC_ALLOW_PUBLIC_DOWNLOADS") trust_proxy_headers: bool = Field(False, validation_alias="FBC_TRUST_PROXY_HEADERS") forwarded_allow_ips: str = Field("127.0.0.1,::1", validation_alias="FBC_FORWARDED_ALLOW_IPS") diff --git a/backend/app/db.py b/backend/app/db.py index 8deeb2f..003add6 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -7,14 +7,19 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio.engine import AsyncEngine from sqlalchemy.orm import declarative_base +from sqlalchemy.pool import StaticPool from .config import settings url: URL = make_url(settings.database_url) -if url.drivername.startswith("sqlite") and url.database: - Path(url.database).expanduser().parent.mkdir(parents=True, exist_ok=True) - -engine: AsyncEngine = create_async_engine(settings.database_url, future=True) +engine_kwargs: dict[str, Any] = {"future": True} +if url.drivername.startswith("sqlite"): + if url.database and url.database != ":memory:": + Path(url.database).expanduser().parent.mkdir(parents=True, exist_ok=True) + else: + engine_kwargs["poolclass"] = StaticPool + +engine: AsyncEngine = create_async_engine(settings.database_url, **engine_kwargs) SessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) Base: Any = declarative_base() diff --git a/backend/app/embed_preview.py b/backend/app/embed_preview.py index cd325e6..d16c838 100644 --- a/backend/app/embed_preview.py +++ b/backend/app/embed_preview.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.app import models, utils +from backend.app.config import settings templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) @@ -48,6 +49,15 @@ async def render_embed_preview(request: Request, db: AsyncSession, token_row: mo mime_type = first_media.mimetype or "application/octet-stream" is_video = mime_type.startswith("video/") is_audio = mime_type.startswith("audio/") + media_url = str(request.url_for("stream_file", download_token=token_row.download_token, upload_id=first_media.public_id)) + preview_url = None + if is_video and utils.should_generate_video_preview(first_media.size_bytes, min_size_bytes=settings.embed_preview_min_size_bytes): + candidate_preview_url = str( + request.url_for("get_file_preview", download_token=token_row.download_token, upload_id=first_media.public_id) + ) + preview_path = utils.get_preview_path(first_media.storage_path or "") if first_media.storage_path else None + if preview_path and preview_path.is_file() and preview_path.stat().st_size > 0: + preview_url = candidate_preview_url return templates.TemplateResponse( request=request, @@ -58,7 +68,8 @@ async def render_embed_preview(request: Request, db: AsyncSession, token_row: mo "description": f"{len(uploads)} file(s) shared" if len(uploads) > 1 else "Shared file", "og_type": "video.other" if is_video else "music.song", "share_url": str(request.url_for("share_page", token=token_row.download_token)), - "media_url": str(request.url_for("stream_file", download_token=token_row.download_token, upload_id=first_media.public_id)), + "media_url": media_url, + "embed_media_url": preview_url if preview_url and not user else media_url, "download_url": str(request.url_for("download_file", download_token=token_row.download_token, upload_id=first_media.public_id)), "thumbnail_url": str( request.url_for("get_file_thumbnail", download_token=token_row.download_token, upload_id=first_media.public_id) diff --git a/backend/app/main.py b/backend/app/main.py index c91ecb6..9380532 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,9 +48,9 @@ async def lifespan(app: FastAPI): queue = ProcessingQueue() queue.start_worker() app.state.processing_queue = queue - app.state.thumbnail_backfill_task = asyncio.create_task( + app.state.media_sidecar_backfill_task = asyncio.create_task( backfill_missing_video_thumbnails(), - name="thumbnail_backfill", + name="media_sidecar_backfill", ) if not settings.skip_cleanup: @@ -58,12 +58,12 @@ async def lifespan(app: FastAPI): yield - thumbnail_backfill_task: asyncio.Task | None = getattr(app.state, "thumbnail_backfill_task", None) - if thumbnail_backfill_task: - if not thumbnail_backfill_task.done(): - thumbnail_backfill_task.cancel() + media_sidecar_backfill_task: asyncio.Task | None = getattr(app.state, "media_sidecar_backfill_task", None) + if media_sidecar_backfill_task: + if not media_sidecar_backfill_task.done(): + media_sidecar_backfill_task.cancel() with suppress(asyncio.CancelledError, Exception): - await thumbnail_backfill_task + await media_sidecar_backfill_task await queue.stop_worker() diff --git a/backend/app/postprocessing.py b/backend/app/postprocessing.py index 5fff74a..431b6b7 100644 --- a/backend/app/postprocessing.py +++ b/backend/app/postprocessing.py @@ -24,10 +24,12 @@ from .utils import ( detect_mimetype, ensure_faststart_mp4, + ensure_video_preview, ensure_video_thumbnail, extract_ffprobe_metadata, get_mp4_remux_skip_reason, is_multimedia, + preview_exists, remux_to_mp4, should_remux_to_mp4, thumbnail_exists, @@ -88,6 +90,7 @@ async def _apply_media_normalization(record: models.UploadRecord, path: Path) -> logger.exception("Failed to apply faststart to upload %s", record.public_id) await _ensure_thumbnail(record, path) + await _ensure_preview(record, path, ffprobe_data) return path, ffprobe_data @@ -124,6 +127,26 @@ async def _ensure_thumbnail(record: models.UploadRecord | SimpleNamespace, path: logger.info("Thumbnail ready for upload %s", record.public_id) +async def _ensure_preview(record: models.UploadRecord | SimpleNamespace, path: Path, ffprobe_data: dict | None) -> None: + mimetype = getattr(record, "mimetype", None) + if not mimetype or not mimetype.startswith("video/"): + return + + try: + preview_path = await ensure_video_preview( + path, + ffprobe_data=ffprobe_data, + clip_seconds=settings.embed_preview_clip_seconds, + min_size_bytes=settings.embed_preview_min_size_bytes, + ) + except Exception: + logger.exception("Failed to generate embed preview for upload %s", record.public_id) + return + + if preview_path is not None: + logger.info("Embed preview ready for upload %s", record.public_id) + + async def _mark_upload_failed(upload_id: str, error_message: str) -> bool: async with SessionLocal() as session: if not (record := await _get_upload_record(session, upload_id)): @@ -285,7 +308,7 @@ async def process_upload(upload_id: str) -> bool: async def backfill_missing_video_thumbnails() -> int: - """Generate thumbnails for completed video uploads that predate thumbnail support.""" + """Generate thumbnails and embed previews for completed video uploads missing sidecars.""" async with SessionLocal() as session: stmt = ( select(models.UploadRecord, models.UploadToken.expires_at) @@ -293,18 +316,18 @@ async def backfill_missing_video_thumbnails() -> int: .where(models.UploadRecord.status == "completed") ) result = await session.execute(stmt) - upload_ids = [ - record.public_id + upload_targets = [ + (record.public_id, not thumbnail_exists(record.storage_path), not preview_exists(record.storage_path)) for record, expires_at in result.all() if record.storage_path and record.mimetype and record.mimetype.startswith("video/") and not _token_has_expired(expires_at) - and not thumbnail_exists(record.storage_path) + and (not thumbnail_exists(record.storage_path) or not preview_exists(record.storage_path)) ] - generated_count = 0 - for upload_id in upload_ids: + updated_count = 0 + for upload_id, needs_thumbnail, needs_preview in upload_targets: async with SessionLocal() as session: stmt = ( select(models.UploadRecord, models.UploadToken.expires_at) @@ -324,21 +347,40 @@ async def backfill_missing_video_thumbnails() -> int: continue path = Path(record.storage_path) + ffprobe_data = record.meta_data.get("ffprobe") if isinstance(record.meta_data, dict) else None if not path.exists(): - logger.warning("Skipping thumbnail backfill for upload %s because file is missing", upload_id) + logger.warning("Skipping sidecar backfill for upload %s because file is missing", upload_id) continue - try: - thumbnail_path = await ensure_video_thumbnail(path) - except Exception: - logger.exception("Failed to backfill thumbnail for upload %s", upload_id) - continue + generated_any = False + if needs_thumbnail: + try: + thumbnail_path = await ensure_video_thumbnail(path) + except Exception: + logger.exception("Failed to backfill thumbnail for upload %s", upload_id) + else: + if thumbnail_path is not None: + generated_any = True + + if needs_preview: + try: + preview_path = await ensure_video_preview( + path, + ffprobe_data=ffprobe_data, + clip_seconds=settings.embed_preview_clip_seconds, + min_size_bytes=settings.embed_preview_min_size_bytes, + ) + except Exception: + logger.exception("Failed to backfill embed preview for upload %s", upload_id) + else: + if preview_path is not None: + generated_any = True - if thumbnail_path is not None: - generated_count += 1 + if generated_any: + updated_count += 1 - if generated_count > 0: - logger.info("Backfilled thumbnails for %s upload(s)", generated_count) + if updated_count > 0: + logger.info("Backfilled media sidecars for %s upload(s)", updated_count) - return generated_count + return updated_count diff --git a/backend/app/routers/tokens.py b/backend/app/routers/tokens.py index 2d4af88..7ce4c6f 100644 --- a/backend/app/routers/tokens.py +++ b/backend/app/routers/tokens.py @@ -35,8 +35,9 @@ def _generate_token_value(num_bytes: int, prefix: str = "") -> str: def _get_thumbnail_fallback_path() -> Path: repo_root = Path(__file__).resolve().parents[3] candidates = ( - Path(settings.frontend_export_path).resolve() / "images" / "thumbnail-fallback.jpg", - repo_root / "frontend" / "public" / "images" / "thumbnail-fallback.jpg", + repo_root / "frontend" / "app" / "assets" / "images" / "thumbnail-fallback.jpg", + repo_root / "frontend" / "public" / "assets" / "images" / "thumbnail-fallback.jpg", + Path(settings.frontend_export_path).resolve() / "assets" / "images" / "thumbnail-fallback.jpg", ) for candidate in candidates: @@ -413,7 +414,9 @@ async def get_file_info( @router.get("/{download_token}/uploads/{upload_id}/thumbnail", name="get_file_thumbnail", response_model=None) +@router.head("/{download_token}/uploads/{upload_id}/thumbnail", response_model=None) @router.get("/{download_token}/uploads/{upload_id}/thumbnail/", response_model=None) +@router.head("/{download_token}/uploads/{upload_id}/thumbnail/", response_model=None) async def get_file_thumbnail( download_token: str, upload_id: str, @@ -444,8 +447,35 @@ async def get_file_thumbnail( ) +@router.get("/{download_token}/uploads/{upload_id}/preview.mp4", name="get_file_preview") +@router.head("/{download_token}/uploads/{upload_id}/preview.mp4", response_model=None) +@router.get("/{download_token}/uploads/{upload_id}/preview.mp4/", response_model=None) +@router.head("/{download_token}/uploads/{upload_id}/preview.mp4/", response_model=None) +async def get_file_preview( + download_token: str, + upload_id: str, + is_admin: Annotated[bool, Depends(optional_admin_check)], +) -> FileResponse: + """Return a generated short MP4 preview for bot embeds when available.""" + async with SessionLocal() as db: + _, _, path = await _get_accessible_upload(download_token, upload_id, db, is_admin) + preview_path = utils.get_preview_path(path) + + if not preview_path.exists() or preview_path.stat().st_size == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preview missing") + + return FileResponse( + preview_path, + filename=preview_path.name, + media_type=utils.PREVIEW_MEDIA_TYPE, + content_disposition_type="inline", + ) + + @router.get("/{download_token}/uploads/{upload_id}/stream", name="stream_file") -@router.get("/{download_token}/uploads/{upload_id}/stream/") +@router.head("/{download_token}/uploads/{upload_id}/stream", response_model=None) +@router.get("/{download_token}/uploads/{upload_id}/stream/", response_model=None) +@router.head("/{download_token}/uploads/{upload_id}/stream/", response_model=None) async def stream_file( download_token: str, upload_id: str, @@ -466,7 +496,9 @@ async def stream_file( @router.get("/{download_token}/uploads/{upload_id}/download", name="download_file") -@router.get("/{download_token}/uploads/{upload_id}/download/") +@router.head("/{download_token}/uploads/{upload_id}/download", response_model=None) +@router.get("/{download_token}/uploads/{upload_id}/download/", response_model=None) +@router.head("/{download_token}/uploads/{upload_id}/download/", response_model=None) async def download_file( download_token: str, upload_id: str, diff --git a/backend/app/templates/share_preview.html b/backend/app/templates/share_preview.html index 01b7e4a..b7ffb0a 100644 --- a/backend/app/templates/share_preview.html +++ b/backend/app/templates/share_preview.html @@ -20,9 +20,9 @@ {% if is_video %} - - - + + + {% if width %} @@ -46,6 +46,15 @@ {% endif %} + {% if is_video %} + + {% if width %} + + {% endif %} + {% if height %} + + {% endif %} + {% endif %} diff --git a/frontend/app/components/MetadataFields.vue b/frontend/app/components/MetadataFields.vue index 332ae1e..70ccf5d 100644 --- a/frontend/app/components/MetadataFields.vue +++ b/frontend/app/components/MetadataFields.vue @@ -7,25 +7,51 @@ * - + - + - + - + - + - +

{{ field.help || field.description }} @@ -36,17 +62,17 @@ diff --git a/frontend/app/components/TokenSummary.vue b/frontend/app/components/TokenSummary.vue index a47661c..3bc3635 100644 --- a/frontend/app/components/TokenSummary.vue +++ b/frontend/app/components/TokenSummary.vue @@ -4,7 +4,9 @@

Remaining uploads: - {{ tokenInfo.remaining_uploads }} / {{ tokenInfo.max_uploads }} + {{ tokenInfo.remaining_uploads }} / {{ tokenInfo.max_uploads }}
@@ -18,16 +20,36 @@
- + Open share page - + Copy share link - + Refresh
@@ -35,8 +57,8 @@ diff --git a/frontend/app/components/UploadsTable.vue b/frontend/app/components/UploadsTable.vue index d46d44c..ad7e25b 100644 --- a/frontend/app/components/UploadsTable.vue +++ b/frontend/app/components/UploadsTable.vue @@ -6,18 +6,25 @@
- #{{ index+1 }} - #{{ index + 1 }} + + :icon="getStatusIcon(row.status)" + > Processing {{ row.status }}
- + {{ row.filename }} {{ row.filename }} @@ -29,7 +36,10 @@
Size - + {{ formatBytes(row.size_bytes ?? row.upload_length ?? 0) }} @@ -38,22 +48,42 @@
-
+
Progress - {{ percent(row.upload_offset, row.upload_length) }} + {{ + percent(row.upload_offset, row.upload_length) + }}
- + Metadata - + @@ -60,16 +78,32 @@ - + @@ -106,14 +140,18 @@ @@ -121,20 +159,20 @@ diff --git a/frontend/app/pages/f/[token].vue b/frontend/app/pages/f/[token].vue index aa08874..cf42b1c 100644 --- a/frontend/app/pages/f/[token].vue +++ b/frontend/app/pages/f/[token].vue @@ -2,15 +2,29 @@
- +
- - + +
@@ -26,8 +40,13 @@ {{ uploads.length }} {{ uploads.length === 1 ? 'file' : 'files' }} available

- + Copy Link
@@ -45,10 +64,19 @@
+ :name=" + tokenInfo.allow_public_downloads + ? 'i-heroicons-lock-open-20-solid' + : 'i-heroicons-lock-closed-20-solid' + " + class="size-4 text-muted" + /> - {{ tokenInfo.allow_public_downloads ? 'Public downloads enabled' : 'Downloads require authentication' }} + {{ + tokenInfo.allow_public_downloads + ? 'Public downloads enabled' + : 'Downloads require authentication' + }}
@@ -62,8 +90,10 @@ - -
-
-
+ +
+
+
- - {{ shouldRenderSelectedMedia ? (selectedIsVideo ? 'Now playing' : 'Now listening') : 'Ready to preview' }} + + {{ + shouldRenderSelectedMedia + ? selectedIsVideo + ? 'Now playing' + : 'Now listening' + : 'Ready to preview' + }}

{{ selectedUpload.filename || 'Untitled media' }}

- - + + Download
-
- -
@@ -130,52 +222,97 @@
-
-
+
Audio Preview
-
{{ selectedUpload.filename || 'Untitled audio' }}
+
+ {{ selectedUpload.filename || 'Untitled audio' }} +
-
-
+
Playback issue

{{ mediaPlaybackError }}

- - + + Download Original @@ -187,41 +324,59 @@
- + Current Source
Type - {{ selectedUpload.mimetype || 'Unknown' }} + {{ + selectedUpload.mimetype || 'Unknown' + }}
Size - {{ formatBytes(selectedUpload.size_bytes || 0) || 'Unknown' }} + {{ + formatBytes(selectedUpload.size_bytes || 0) || 'Unknown' + }}
Duration {{ selectedDurationLabel }}
-
+
Resolution {{ selectedResolutionLabel }}
Uploaded - {{ formatDate(selectedUpload.created_at) }} + {{ + formatDate(selectedUpload.created_at) + }}
-
+
Metadata
-
+
{{ formatKey(key) }}: {{ formatValue(val) }}
@@ -234,21 +389,42 @@ Available Sources
- - + +
@@ -248,7 +265,7 @@ >
- Audio Preview + Click to play
{{ selectedUpload.filename || 'Untitled audio' }} @@ -269,7 +286,7 @@
-
Audio Preview
+
Now listening
{{ selectedUpload.filename || 'Untitled audio' }}
@@ -277,7 +294,7 @@