Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions python/api/media_get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""
Media file serving endpoint for audio and video files.
Supports range requests for seeking functionality.
"""
import os
import io
import base64
from python.helpers.api import ApiHandler, Request, Response, send_file
from python.helpers import files, runtime
from mimetypes import guess_type


class MediaGet(ApiHandler):
"""Serve media files (audio/video) with range request support for seeking."""

@classmethod
def get_methods(cls) -> list[str]:
return ["GET"]

async def process(self, input: dict, request: Request) -> dict | Response:
# Input data
path = input.get("path", request.args.get("path", ""))

if not path:
raise ValueError("No path provided")

# Get file extension and info
file_ext = os.path.splitext(path)[1].lower()
filename = os.path.basename(path)

# Allowed media extensions
audio_extensions = [".wav", ".mp3", ".ogg", ".flac", ".webm", ".m4a", ".aac"]
video_extensions = [".mp4", ".webm", ".ogv", ".mov"]
media_extensions = audio_extensions + video_extensions

if file_ext not in media_extensions:
raise ValueError(f"Unsupported media format: {file_ext}")

# Determine media type
is_audio = file_ext in audio_extensions

# Check file existence and get content
if runtime.is_development():
if files.exists(path):
file_content = files.read_file(path, "rb") if hasattr(files, 'read_file') else None
if file_content is None:
# Use base64 method
b64_content = await runtime.call_development_function(
files.read_file_base64, path
)
file_content = base64.b64decode(b64_content)
elif await runtime.call_development_function(files.exists, path):
b64_content = await runtime.call_development_function(
files.read_file_base64, path
)
file_content = base64.b64decode(b64_content)
else:
raise ValueError(f"Media file not found: {path}")
else:
if files.exists(path):
file_content = files.read_file(path, "rb") if hasattr(files, 'read_file') else None
if file_content is None:
# Read file directly
with open(files.get_abs_path(path) if not os.path.isabs(path) else path, "rb") as f:
file_content = f.read()
else:
raise ValueError(f"Media file not found: {path}")

# Get MIME type
mime_type, _ = guess_type(filename)
if not mime_type:
mime_type = f"audio/{file_ext[1:]}" if is_audio else f"video/{file_ext[1:]}"

# Get file size
file_size = len(file_content) if isinstance(file_content, bytes) else os.path.getsize(path)

# Handle range requests for seeking
range_header = request.headers.get("range")

if range_header:
# Parse range header (e.g., "bytes=0-1023")
try:
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
except (ValueError, IndexError):
start, end = 0, file_size - 1

# Ensure valid range
start = max(0, start)
end = min(file_size - 1, end)
content_length = end - start + 1

# Create partial content
if isinstance(file_content, bytes):
partial_content = file_content[start:end+1]
response = send_file(
io.BytesIO(partial_content),
mimetype=mime_type,
as_attachment=False,
download_name=filename,
)
else:
response = send_file(
io.BytesIO(file_content[start:end+1]),
mimetype=mime_type,
as_attachment=False,
download_name=filename,
)

response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
response.status_code = 206
else:
# Full file request
if isinstance(file_content, bytes):
response = send_file(
io.BytesIO(file_content),
mimetype=mime_type,
as_attachment=False,
download_name=filename,
)
else:
response = send_file(
io.BytesIO(file_content),
mimetype=mime_type,
as_attachment=False,
download_name=filename,
)

# Add headers
response.headers["Accept-Ranges"] = "bytes"
response.headers["Content-Length"] = str(file_size if not range_header else content_length)
response.headers["Cache-Control"] = "public, max-age=3600"
response.headers["X-File-Type"] = "audio" if is_audio else "video"
response.headers["X-File-Name"] = filename

return response
91 changes: 90 additions & 1 deletion webui/css/messages.css
Original file line number Diff line number Diff line change
Expand Up @@ -771,4 +771,93 @@
to {
opacity: 0;
}
}
}



/* ===== MEDIA DISPLAY ENHANCEMENT - Audio/Video Players ===== */

/* Audio player styling - Enhanced visibility with accent colors */
audio {
width: 100%;
max-width: 400px;
height: 44px;
margin: 10px 0;
border-radius: 22px;
border: 2px solid #4a9eff;
background: linear-gradient(135deg, #e8f4ff 0%, #f0f8ff 100%);
box-shadow: 0 3px 8px rgba(74, 158, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}

audio::-webkit-media-controls-panel {
background: transparent;
}

/* Video player styling - Enhanced visibility */
video {
max-width: 100%;
width: 100%;
max-width: 640px;
max-height: 480px;
margin: 10px 0;
border-radius: 12px;
border: 2px solid #4a9eff;
background: #000;
box-shadow: 0 4px 12px rgba(74, 158, 255, 0.4);
}

/* Light mode specific - ensure visibility */
@media (prefers-color-scheme: light) {
audio {
border-color: #2d7dd2;
background: linear-gradient(135deg, #e3f0ff 0%, #eef6ff 100%);
box-shadow: 0 3px 8px rgba(45, 125, 210, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.9);
}

video {
border-color: #2d7dd2;
box-shadow: 0 4px 12px rgba(45, 125, 210, 0.35);
}
}

/* Dark mode adjustments - distinct from background */
@media (prefers-color-scheme: dark) {
audio {
border-color: #6bb3ff;
background: linear-gradient(135deg, #1a3a5c 0%, #0d2137 100%);
box-shadow: 0 3px 8px rgba(107, 179, 255, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}

audio::-webkit-media-controls-panel {
background: transparent;
}

video {
border-color: #6bb3ff;
box-shadow: 0 4px 12px rgba(107, 179, 255, 0.5);
}
}

/* Message content media container */
.message-content audio,
.message-content video {
display: block;
margin: 12px 0;
}

/* Hover effects for better interactivity */
audio:hover {
border-color: #66b3ff;
box-shadow: 0 4px 12px rgba(74, 158, 255, 0.5);
transform: translateY(-1px);
transition: all 0.2s ease;
}

video:hover {
border-color: #66b3ff;
box-shadow: 0 6px 16px rgba(74, 158, 255, 0.6);
transform: translateY(-1px);
transition: all 0.2s ease;
}

/* ===== END MEDIA DISPLAY ENHANCEMENT ===== */
60 changes: 60 additions & 0 deletions webui/js/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ export function _drawMessage({
let processedContent = content;
processedContent = convertImageTags(processedContent);
processedContent = convertImgFilePaths(processedContent);
processedContent = convertMediaFilePaths(processedContent);
processedContent = convertFilePaths(processedContent);
processedContent = marked.parse(processedContent, { breaks: true });
processedContent = convertPathsToLinks(processedContent);
Expand Down Expand Up @@ -2254,3 +2255,62 @@ function smoothRender(element, newContent, delay = 350) {

element.dataset.smoothTimeoutId = String(timeoutId);
}


// ===== Media Display Enhancement - Audio/Video Handlers =====

function convertAudioFilePaths(str) {
// Convert audio:// paths to HTML5 audio players
return str.replace(
/audio:\/\/([^\s"]+\.(wav|mp3|ogg|flac|webm|m4a|aac))/gi,
function(match, path) {
const ext = path.split('.').pop().toLowerCase();
const mimeTypes = {
'wav': 'audio/wav',
'mp3': 'audio/mpeg',
'ogg': 'audio/ogg',
'flac': 'audio/flac',
'webm': 'audio/webm',
'm4a': 'audio/mp4',
'aac': 'audio/aac'
};
const mimeType = mimeTypes[ext] || 'audio/' + ext;
return '<audio controls preload="metadata" style="max-width: 100%; width: 100%; max-width: 400px; margin: 10px 0; border-radius: 8px; background: #f0f0f0;">' +
'<source src="/media_get?path=' + path + '" type="' + mimeType + '">' +
'Your browser does not support the audio element. ' +
'<a href="/download_work_dir_file?path=' + path + '">Download audio</a>' +
'</audio>';
}
);
}

function convertVideoFilePaths(str) {
// Convert video:// paths to HTML5 video players
return str.replace(
/video:\/\/([^\s"]+\.(mp4|webm|ogv|mov))/gi,
function(match, path) {
const ext = path.split('.').pop().toLowerCase();
const mimeTypes = {
'mp4': 'video/mp4',
'webm': 'video/webm',
'ogv': 'video/ogg',
'mov': 'video/quicktime'
};
const mimeType = mimeTypes[ext] || 'video/' + ext;
return '<video controls preload="metadata" style="max-width: 100%; width: 100%; max-width: 640px; max-height: 480px; margin: 10px 0; border-radius: 8px; background: #000;">' +
'<source src="/media_get?path=' + path + '" type="' + mimeType + '">' +
'Your browser does not support the video element. ' +
'<a href="/download_work_dir_file?path=' + path + '">Download video</a>' +
'</video>';
}
);
}

function convertMediaFilePaths(str) {
// Process all media types
let result = convertAudioFilePaths(str);
result = convertVideoFilePaths(result);
return result;
}

// ===== End Media Display Enhancement =====