API de Streaming HLS¶
Endpoint para obter URLs assinadas de streaming HLS direto (Bunny CDN), permitindo uso de player customizado com controle total de playback.
Contexto¶
Os videos da plataforma sao hospedados no Bunny.net Stream (library 397476, hostname vz-8ac53877-fe8.b-cdn.net). Por padrao, o acesso direto ao .m3u8 e bloqueado (403). Este endpoint gera URLs temporarias assinadas usando CDN Token Authentication V2 (path-style), que permitem acesso direto ao HLS para players customizados.
Por que path-style? Um stream HLS e composto por:
playlist.m3u8(master playlist — lista de qualidades)- Sub-playlists por qualidade (
360p/video.m3u8,720p/video.m3u8, etc.) - Segmentos
.ts(pedacos do video, ~2s cada)
Se o token estiver na query string (?token=xxx), apenas o master playlist carrega — os sub-requests (qualidades e segments) perdem o token e dao 403. O path-style com token_path resolve isso: o token fica no prefixo da URL, e todas as sub-requests herdam automaticamente.
❌ Query string (segments perdem token):
https://vz-xxx.b-cdn.net/{guid}/playlist.m3u8?token=abc&expires=123
→ playlist.m3u8 ✅ 200
→ 360p/video.m3u8 ❌ 403 (token perdido)
✅ Path-style (segments herdam token):
https://vz-xxx.b-cdn.net/bcdn_token=abc&expires=123&token_path=/{guid}/playlist.m3u8
→ playlist.m3u8 ✅ 200
→ 360p/video.m3u8 ✅ 200 (token herdado)
→ 360p/seg-1.ts ✅ 200 (token herdado)
Endpoint¶
GET /api/v1/videos/{video_id}/stream-url¶
Gera uma URL HLS assinada temporaria para um video especifico.
Autenticacao: Bearer token (JWT) obrigatorio. Assinatura ativa: Customer precisa de assinatura ativa. Admin tem bypass.
Request¶
| Parametro | Tipo | Local | Descricao |
|---|---|---|---|
video_id |
ObjectId |
path | ID do video no MongoDB (campo _id, nao o GUID do Bunny) |
Response (200 OK)¶
{
"stream_url": "https://vz-8ac53877-fe8.b-cdn.net/bcdn_token=YlwMACz_ftzrPouD_TWexioRXgcMiM-LaUeLPfqCvxI&expires=1774574694&token_path=/69787c50-0259-4c09-a43e-849ab148450e/playlist.m3u8",
"expires_at": "2026-03-26T23:19:43+00:00"
}
| Campo | Tipo | Descricao |
|---|---|---|
stream_url |
string |
URL HLS assinada (path-style). Passar diretamente ao VideoPlayerController.networkUrl() |
expires_at |
string (ISO 8601) |
Timestamp de expiracao da URL (UTC). TTL padrao: 4 horas |
Qualidades disponiveis¶
O master playlist retorna as qualidades configuradas no Bunny Stream. Exemplo real:
| Qualidade | Resolucao | Bandwidth |
|---|---|---|
| 240p | 426x240 | ~1.0 Mbps |
| 360p | 640x360 | ~1.4 Mbps |
| 480p | 854x480 | ~2.4 Mbps |
| 720p | 1280x720 | ~4.7 Mbps |
| 1080p | 1920x1080 | ~8.2 Mbps |
O player (video_player/chewie) seleciona automaticamente a qualidade com base na conexao.
Errors¶
| HTTP | Code | Quando | Acao no app |
|---|---|---|---|
| 401 | UNAUTHORIZED |
Token JWT ausente ou invalido | Redirecionar para login |
| 403 | FORBIDDEN / SUBSCRIPTION_REQUIRED |
Customer sem assinatura ativa | Mostrar tela de subscribe |
| 404 | NOT_FOUND |
Video nao existe ou nao e Bunny Stream | Usar iframe como fallback |
| 500 | EXTERNAL_SERVICE_ERROR |
Bunny CDN nao configurado no backend | Usar iframe como fallback |
Integracao Flutter/Dart¶
1. Service: obter URL de stream¶
import 'dart:convert';
import 'package:http/http.dart' as http;
class StreamingService {
final String baseUrl;
final String token;
StreamingService({required this.baseUrl, required this.token});
/// Busca URL HLS assinada para um video.
/// Retorna null se falhar (fallback para iframe).
Future<StreamUrlResponse?> getStreamUrl(String videoId) async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/v1/videos/$videoId/stream-url'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return StreamUrlResponse(
streamUrl: data['stream_url'],
expiresAt: DateTime.parse(data['expires_at']),
);
}
// 404 = video nao e Bunny Stream → usar iframe
// 403 = sem assinatura → tratar na UI
// 500 = CDN nao configurado → usar iframe
return null;
} catch (e) {
return null;
}
}
}
class StreamUrlResponse {
final String streamUrl;
final DateTime expiresAt;
StreamUrlResponse({required this.streamUrl, required this.expiresAt});
/// Verifica se a URL ainda e valida (com margem de 5 minutos)
bool get isExpired =>
DateTime.now().toUtc().isAfter(expiresAt.subtract(Duration(minutes: 5)));
}
2. Player customizado com controle total¶
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
class VideoPlayerScreen extends StatefulWidget {
final String videoId; // MongoDB _id
final double? savedTimeline; // posicao salva (segundos)
const VideoPlayerScreen({
required this.videoId,
this.savedTimeline,
});
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
VideoPlayerController? _videoController;
ChewieController? _chewieController;
StreamUrlResponse? _streamData;
Timer? _saveTimer;
bool _useCustomPlayer = false;
@override
void initState() {
super.initState();
_initPlayer();
}
Future<void> _initPlayer() async {
// 1. Obter URL assinada
final streamingService = StreamingService(
baseUrl: AppConfig.apiBaseUrl,
token: AuthService.currentToken,
);
_streamData = await streamingService.getStreamUrl(widget.videoId);
if (_streamData != null) {
// 2. Player customizado (HLS direto)
_useCustomPlayer = true;
_videoController = VideoPlayerController.networkUrl(
Uri.parse(_streamData!.streamUrl),
);
await _videoController!.initialize();
_chewieController = ChewieController(
videoPlayerController: _videoController!,
autoPlay: true,
// Retomar de onde parou
startAt: widget.savedTimeline != null
? Duration(seconds: widget.savedTimeline!.toInt())
: Duration.zero,
allowPlaybackSpeedChanging: true,
playbackSpeeds: [0.5, 0.75, 1.0, 1.25, 1.5, 2.0],
allowFullScreen: true,
showControlsOnInitialize: false,
);
// 3. Iniciar save de progresso a cada 15s
_startProgressSaving();
setState(() {});
} else {
// Fallback: iframe embed
_useCustomPlayer = false;
setState(() {});
}
}
void _startProgressSaving() {
_saveTimer = Timer.periodic(Duration(seconds: 15), (_) async {
final position = await _videoController?.position;
if (position != null && position.inSeconds > 0) {
await _saveWatchHistory(position.inMilliseconds / 1000.0);
}
});
}
Future<void> _saveWatchHistory(double timeline) async {
await http.put(
Uri.parse('${AppConfig.apiBaseUrl}/api/v1/me/watch-history'),
headers: {
'Authorization': 'Bearer ${AuthService.currentToken}',
'Content-Type': 'application/json',
},
body: jsonEncode({
'video_id': widget.videoId,
'timeline': timeline,
}),
);
}
@override
Widget build(BuildContext context) {
if (_useCustomPlayer && _chewieController != null) {
return Chewie(controller: _chewieController!);
}
// Fallback: iframe embed
return WebViewWidget(/* iframe embed URL */);
}
@override
void dispose() {
_saveTimer?.cancel();
// Salvar posicao final ao sair
_videoController?.position.then((pos) {
if (pos != null) _saveWatchHistory(pos.inMilliseconds / 1000.0);
});
_chewieController?.dispose();
_videoController?.dispose();
super.dispose();
}
}
3. Dialog "Continuar assistindo?"¶
/// Ao abrir o video, verificar se existe posicao salva
Future<double?> getSavedTimeline(String videoId) async {
final response = await http.get(
Uri.parse('${AppConfig.apiBaseUrl}/api/v1/me/watch-history?video_id=$videoId'),
headers: {'Authorization': 'Bearer ${AuthService.currentToken}'},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final docs = data['docs'] as List?;
if (docs != null && docs.isNotEmpty) {
return (docs[0]['timeline'] as num?)?.toDouble();
}
}
return null;
}
/// Mostrar dialog se timeline > 10 segundos
Future<double?> showResumeDialog(BuildContext context, double timeline) async {
final minutes = (timeline / 60).floor();
final seconds = (timeline % 60).floor();
final resume = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Continuar assistindo?'),
content: Text('Voce parou em ${minutes}m${seconds}s'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Comecar do inicio'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('Continuar'),
),
],
),
);
return (resume == true) ? timeline : null;
}
4. Renovacao de URL expirada¶
/// Chamar antes de operacoes de seek ou ao retomar playback
Future<void> refreshStreamUrlIfNeeded() async {
if (_streamData == null || !_streamData!.isExpired) return;
final newStream = await _streamingService.getStreamUrl(widget.videoId);
if (newStream != null) {
_streamData = newStream;
// Salvar posicao atual
final currentPos = await _videoController?.position;
// Recriar controller com nova URL
_chewieController?.dispose();
_videoController?.dispose();
_videoController = VideoPlayerController.networkUrl(
Uri.parse(newStream.streamUrl),
);
await _videoController!.initialize();
if (currentPos != null) {
await _videoController!.seekTo(currentPos);
}
// Recriar chewie
_chewieController = ChewieController(
videoPlayerController: _videoController!,
autoPlay: true,
);
setState(() {});
}
}
Fluxo Completo¶
sequenceDiagram
participant App as Flutter App
participant API as fdplay-api
participant DB as MongoDB
participant CDN as Bunny CDN
Note over App: Usuario abre um video
App->>API: GET /me/watch-history?video_id=XXX
API->>DB: Buscar watch_history
DB-->>API: { timeline: 542.8 }
API-->>App: Posicao salva
alt timeline > 10s
App->>App: Dialog "Continuar em 9m02s?"
end
App->>API: GET /api/v1/videos/{id}/stream-url<br/>Authorization: Bearer <token>
API->>API: Validar JWT + subscription ativa
API->>DB: Buscar video por _id
DB-->>API: video_id = "https://iframe.mediadelivery.net/embed/397476/{guid}"
API->>API: Extrair GUID da URL
API->>API: SHA256(key + token_path + expires) → Base64 URL-safe
API-->>App: { stream_url, expires_at }
App->>CDN: GET /bcdn_token=xxx&expires=yyy&token_path=/{guid}/playlist.m3u8
CDN-->>App: Master playlist (240p, 360p, 480p, 720p, 1080p)
App->>CDN: GET .../{guid}/720p/video.m3u8 (token herdado)
CDN-->>App: Sub-playlist (689 segments)
App->>CDN: GET .../{guid}/720p/seg-1.ts (token herdado)
CDN-->>App: Video data (bytes)
Note over App: Video tocando...
loop A cada 15 segundos
App->>API: PUT /me/watch-history<br/>{ video_id, timeline: 557.3 }
end
Note over App: Usuario fecha o video
App->>API: PUT /me/watch-history<br/>{ video_id, timeline: 892.1 } (posicao final)
Endpoints Relacionados (Watch History)¶
| Metodo | Endpoint | Body/Query | Descricao |
|---|---|---|---|
PUT |
/api/v1/me/watch-history |
{ "video_id": "xxx", "timeline": 542.8 } |
Salvar posicao (upsert) |
GET |
/api/v1/me/watch-history?video_id=XXX |
- | Recuperar posicao salva |
GET |
/api/v1/me/watch-history |
- | Listar todo historico |
DELETE |
/api/v1/me/watch-history |
- | Limpar historico completo |
Ver documentacao completa em API Profile.
Checklist de Integracao Frontend¶
- [ ] Adicionar
StreamingServicecomgetStreamUrl() - [ ] Implementar
VideoPlayerScreencom player customizado + fallback iframe - [ ] Implementar dialog "Continuar assistindo?" (buscar watch-history antes de iniciar)
- [ ] Implementar
Timer.periodicpara salvar progresso a cada 15s - [ ] Salvar posicao final no
dispose()do player - [ ] Implementar renovacao de URL expirada (
isExpiredcheck) - [ ] Toggle
useCustomPlayerpara ativar/desativar player customizado - [ ] Testar em iOS, Android e Web
Notas Tecnicas¶
- TTL da URL: 4 horas (14400 segundos). Se o usuario assistir por mais tempo, o app deve solicitar nova URL antes da expiracao (ver secao "Renovacao de URL expirada").
- Token Auth V2: Hash =
SHA256_raw(key + token_path + expires)→ Base64 URL-safe (replace+→-,/→_, remove=). - Formato da URL:
https://vz-8ac53877-fe8.b-cdn.net/bcdn_token={token}&expires={ts}&token_path=/{guid}/playlist.m3u8 - Fallback: Se
getStreamUrl()retornarnull(video nao-Bunny ou erro), usar iframe embed como fallback. O app nao quebra. - Seguranca: A URL assinada e vinculada ao diretorio do video (
token_path=/{guid}/). Nao permite acesso a outros videos. - Cache: O frontend pode cachear a
stream_urlateexpires_at. Nao precisa solicitar nova URL a cada play/pause. - Qualidades: O player seleciona automaticamente a melhor qualidade. Nao precisa de logica custom para adaptive bitrate — o HLS faz isso nativamente.
- Testado: Master playlist, sub-playlists (5 qualidades) e segments .ts validados com sucesso contra o CDN.