Skip to content
Merged
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
59 changes: 59 additions & 0 deletions backend/app/api/v1/endpoints/videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,61 @@ def check_premium_access(db: Session, request: Request):
raise HTTPException(status_code=403, detail="1080p and above quality levels are restricted to Premium members")


@router.get("/{video_id}/stream-url")
async def get_stream_url(video_id: str, request: Request, db: Session = Depends(get_db)):
"""Return a CloudFront signed URL for the video's HLS manifest.

Client fetches this ONCE, then loads manifest + all segments directly
from CloudFront — no backend proxy involved in actual streaming.

Auth/premium check runs here (same logic as the old proxy endpoint).
If CloudFront signing is not configured, falls back to the raw CDN URL.
"""
quality = request.query_params.get("quality", "original")
if quality.lower() in ("1080p", "2k", "4k"):
check_premium_access(db, request)

video = get_video_db(db, video_id)
if not video:
raise HTTPException(status_code=404, detail="Video not found")

base_url = video.video_url
if not base_url:
raise HTTPException(status_code=400, detail="Video has no URL")

# Resolve to CDN URL if stored as S3/HF path
if "amazonaws.com" in base_url or "monteeq.s3" in base_url or "cdn.monteeq.com" in base_url:
try:
if ".com/" in base_url:
parts = base_url.split(".com/")
if len(parts) > 1:
base_url = storage.get_url(parts[1])
except Exception as e:
logger.warning(f"Failed to resolve CDN URL for stream-url: {e}")

# If CloudFront signing is not configured, return the raw URL
# (the old proxy endpoint is still available as fallback)
from app.utils.cloudfront_signer import is_cloudfront_configured, generate_signed_url

if not is_cloudfront_configured():
return {
"url": base_url,
"expires_at": 0,
"signed": False,
}

# Video duration + 1 hour buffer, capped at 6 hours
duration_buffer = min((video.duration or 3600) + 3600, 21600)
signed_url = generate_signed_url(base_url, duration_buffer)

import time
return {
"url": signed_url,
"expires_at": int(time.time()) + duration_buffer,
"signed": True,
}


@router.get("/{video_id}/stream/{sub_path:path}")
@router.get("/{video_id}/stream")
async def stream_video(video_id: str, request: Request, db: Session = Depends(get_db), sub_path: str = None):
Expand Down Expand Up @@ -236,6 +291,8 @@ async def get_stream():
response_headers = {
"Accept-Ranges": "bytes",
"Content-Encoding": "identity",
"X-Deprecated": "true",
"X-Deprecation-Notice": "Use /stream-url for direct CDN access",
}
content_type = resp.headers.get("Content-Type")
if content_type:
Expand Down Expand Up @@ -326,6 +383,8 @@ async def get_res_stream():
response_headers = {
"Accept-Ranges": "bytes",
"Content-Encoding": "identity",
"X-Deprecated": "true",
"X-Deprecation-Notice": "Use /stream-url for direct CDN access",
}
content_type = resp.headers.get("Content-Type")
if content_type:
Expand Down
4 changes: 4 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ def _parse_origins(raw: str) -> list[str]:
S3_ENDPOINT = _raw_endpoint.strip() or None # empty string → native AWS
AWS_S3_USE_ACCELERATE = os.getenv("AWS_S3_USE_ACCELERATE", "false").lower() == "true"
AWS_CLOUDFRONT_DOMAIN = os.getenv("AWS_CLOUDFRONT_DOMAIN", "")
CLOUDFRONT_KEY_PAIR_ID = os.getenv("CLOUDFRONT_KEY_PAIR_ID", "")
# NOTE: For production, consider migrating this to AWS Secrets Manager
# (fetched at startup, cached in memory) instead of an env var.
CLOUDFRONT_PRIVATE_KEY = os.getenv("CLOUDFRONT_PRIVATE_KEY", "")

PAYSTACK_SECRET_KEY = os.getenv("PAYSTACK_SECRET_KEY", "")
# STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
Expand Down
74 changes: 74 additions & 0 deletions backend/app/utils/cloudfront_signer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
CloudFront signed URL generation for direct CDN video streaming.

Uses a custom policy with a path wildcard so the signed URL covers:
- The master manifest (master.m3u8)
- All segment files (.ts/.m4s) referenced by the manifest

AWS requires SHA-1 for CloudFront signing (hard requirement, not a choice).
"""

import re
import logging
from datetime import datetime, timezone
from urllib.parse import quote

from app.core import config

logger = logging.getLogger(__name__)


def _rsa_signer(message: bytes) -> bytes:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

private_key = serialization.load_pem_private_key(
config.CLOUDFRONT_PRIVATE_KEY.encode(), password=None
)
return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())


def _get_signer():
from botocore.signers import CloudFrontSigner
return CloudFrontSigner(config.CLOUDFRONT_KEY_PAIR_ID, _rsa_signer)


def generate_signed_url(resource_url: str, expires_in_seconds: int = 21600) -> str:
"""
Generate a CloudFront signed URL with a custom policy.

The policy covers the video's directory path with a wildcard (*),
so the manifest AND all segment files are accessible with one signature.

Args:
resource_url: Full CDN URL to the master manifest, e.g.
https://d123.cloudfront.net/videos/123/hls/master.m3u8
expires_in_seconds: Time until the signed URL expires (default 6h).

Returns:
Signed URL string.
"""
signer = _get_signer()

# Build wildcard resource: replace the filename with *
# https://d123.cloudfront.net/videos/123/hls/master.m3u8
# → https://d123.cloudfront.net/videos/123/hls/*
base_path = re.sub(r'[^/]+\.m3u8$', '*', resource_url)

expiration = datetime.now(timezone.utc).timestamp() + expires_in_seconds
expiration_dt = datetime.fromtimestamp(expiration, tz=timezone.utc)

policy = signer.build_policy(
resource=base_path,
date_less_than=expiration_dt,
)

return signer.generate_presigned_url(
resource_url,
policy=policy,
)


def is_cloudfront_configured() -> bool:
"""Check if CloudFront signing is configured."""
return bool(config.CLOUDFRONT_KEY_PAIR_ID and config.CLOUDFRONT_PRIVATE_KEY)
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ brotli
fastapi-cache2[redis]
tenacity
stripe
cryptography
46 changes: 26 additions & 20 deletions next-app/src/app/(embed)/embed/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default async function EmbedPage({ params, searchParams }) {
const { id } = params;
const autoplay = searchParams?.autoplay === '1';
const video = await loadVideo(id);
const apiOrigin = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/$/, '');

if (!video) {
return (
Expand All @@ -60,26 +61,30 @@ export default async function EmbedPage({ params, searchParams }) {
const watchUrl = `${siteOrigin()}/watch/${video.id}`;

return (
<div style={{
position: 'relative',
width: '100vw',
height: '100vh',
background: '#000',
overflow: 'hidden',
}}>
<VideoPlayerV2
src={video.video_url}
videoId={video.id}
title={video.title}
creator={video.owner?.username || ''}
poster={video.thumbnail_url}
autoPlay={autoplay}
url_480p={video.url_480p}
url_720p={video.url_720p}
url_1080p={video.url_1080p}
url_2k={video.url_2k}
url_4k={video.url_4k}
/>
<>
<link rel="dns-prefetch" href={apiOrigin} />
<link rel="preconnect" href={apiOrigin} crossOrigin="anonymous" />
<div style={{
position: 'relative',
width: '100vw',
height: '100vh',
background: '#000',
overflow: 'hidden',
}}>
<VideoPlayerV2
src={video.video_url}
videoId={video.id}
title={video.title}
creator={video.owner?.username || ''}
poster={video.thumbnail_url}
autoPlay={autoplay}
fastStartMode
url_480p={video.url_480p}
url_720p={video.url_720p}
url_1080p={video.url_1080p}
url_2k={video.url_2k}
url_4k={video.url_4k}
/>

<Link
href={watchUrl}
Expand Down Expand Up @@ -113,5 +118,6 @@ export default async function EmbedPage({ params, searchParams }) {
Monteeq
</Link>
</div>
</>
);
}
3 changes: 3 additions & 0 deletions next-app/src/app/(main)/watch/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,12 @@ export default async function WatchPage({ params }) {
const { video, comments, relatedVideos, followersCount, isFollowing } = data;
const canonical = `${siteOrigin()}/watch/${video.id}`;
const jsonLd = buildVideoJsonLd(video, canonical);
const apiOrigin = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/$/, '');

return (
<>
<link rel="dns-prefetch" href={apiOrigin} />
<link rel="preconnect" href={apiOrigin} crossOrigin="anonymous" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
Expand Down
80 changes: 62 additions & 18 deletions next-app/src/components/player/VideoPlayerV2.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Hls from 'hls.js';
import { Play, Pause, Volume2, VolumeX, Maximize, Square, Monitor, Settings, RotateCcw, RotateCw, AlertCircle, Loader2, Crown, SkipBack, SkipForward } from 'lucide-react';
import '@/styles/components/VideoPlayerV2.css';
import { initView, sendHeartbeat } from '@/lib/clientApi';
import { getStreamUrl, getClientApiBaseUrl } from '@/lib/streamUrl';
import { getStreamUrl, getClientApiBaseUrl, fetchStreamSignedUrl } from '@/lib/streamUrl';
import { useAuth } from '@/context/AuthContext';
import PreRollPlayer from '@/components/ads/PreRollPlayer';
import PauseOverlayAd from '@/components/ads/PauseOverlayAd';
Expand Down Expand Up @@ -42,6 +42,8 @@ const VideoPlayerV2 = ({
url_1080p,
url_2k,
url_4k,
// Performance: force lowest bitrate start + aggressive buffering for fastest first frame
fastStartMode = false,
}) => {
const { token, user } = useAuth();
const videoRef = useRef(null);
Expand Down Expand Up @@ -175,9 +177,7 @@ const VideoPlayerV2 = ({
pendingSeekRef.current = savedTime;
}

const streamSrc = `${getStreamUrl(src, videoId)}${token ? `?token=${token}` : ''}`;
const srcToUse = streamSrc || src;
let recoveryAttempts = 0;
let destroyed = false;

const playAfterLoad = () => {
if (!videoRef.current) return;
Expand Down Expand Up @@ -206,22 +206,30 @@ const VideoPlayerV2 = ({
videoEl.addEventListener('resize', handleVideoResize);
}

if (Hls.isSupported() && srcToUse.includes('.m3u8')) {
/**
* Initialize HLS.js with a given source URL.
* Handles manifest parsing, resolution detection, ABR capping, and error recovery.
* On 403 (expired signed URL), re-fetches and reloads up to 2 times.
*/
const initHls = (sourceUrl, retryCount = 0) => {
if (destroyed || !videoRef.current) return;

if (hlsRef.current) hlsRef.current.destroy();
let recoveryAttempts = 0;

const hls = new Hls({
capLevelToPlayerSize: false,
startLevel: 0, // start with lowest quality for fastest first frame
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: 0,
maxBufferLength: fastStartMode ? 5 : 10,
maxMaxBufferLength: fastStartMode ? 15 : 30,
maxBufferSize: fastStartMode ? 5 * 1024 * 1024 : 10 * 1024 * 1024,
abrEwmaDefaultEstimate: 3000000,
startFragPrefetch: true,
lowLatencyMode: false,
progressive: true, // start playing as soon as first segment arrives
xhrSetup: (xhr, url) => {
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
}
progressive: true,
// No xhrSetup auth header needed — signed URL carries its own auth
});
hls.loadSource(srcToUse);
hls.loadSource(sourceUrl);
hls.attachMedia(videoRef.current);
hlsRef.current = hls;

Expand Down Expand Up @@ -280,6 +288,21 @@ const VideoPlayerV2 = ({

hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
// Signed URL expired (403) — re-fetch and reload
if (data.type === Hls.ErrorTypes.NETWORK_ERROR &&
data.response?.code === 403 &&
retryCount < 2) {
console.warn('[HLS] Signed URL expired, re-fetching...');
fetchStreamSignedUrl(videoId, null, token)
.then(({ url }) => {
if (!destroyed) initHls(url, retryCount + 1);
})
.catch(() => {
if (!destroyed) setError('Failed to refresh stream URL.');
});
return;
}

if (recoveryAttempts >= 3) {
setError('Failed to load video stream.');
return;
Expand All @@ -300,16 +323,36 @@ const VideoPlayerV2 = ({
}
}
});
};

if (Hls.isSupported() && src && src.startsWith('http')) {
// Fetch signed URL from backend, then initialize HLS
fetchStreamSignedUrl(videoId, null, token)
.then(({ url }) => {
if (!destroyed) initHls(url);
})
.catch((err) => {
console.warn('[HLS] Failed to get signed URL, falling back to proxy:', err);
if (!destroyed) {
// Fallback: use the legacy backend proxy URL
const fallbackUrl = `${getStreamUrl(src, videoId)}${token ? `?token=${token}` : ''}`;
initHls(fallbackUrl);
}
});

return () => {
hls.destroy();
hlsRef.current = null;
destroyed = true;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (videoEl) {
videoEl.removeEventListener('resize', handleVideoResize);
}
};
} else {
// Direct MP4 / non-HLS stream
const srcToUse = src;
videoRef.current.src = srcToUse;
const onMeta = () => {
playAfterLoad();
Expand All @@ -318,13 +361,14 @@ const VideoPlayerV2 = ({
videoRef.current.addEventListener('loadedmetadata', onMeta);
videoRef.current.load();
return () => {
destroyed = true;
videoEl?.removeEventListener('loadedmetadata', onMeta);
if (videoEl) {
videoEl.removeEventListener('resize', handleVideoResize);
}
};
}
}, [src, autoPlay, videoId, token, isPremium, getResolutionDetails]);
}, [src, autoPlay, videoId, token, isPremium, getResolutionDetails, fastStartMode, selectedQuality]);

// Handle Hls.js level selection smoothly
useEffect(() => {
Expand Down
Loading
Loading