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",
"subtitles": [
{
"lang": "pt",
"label": "Português",
"url": "https://fdplay-api.infraifd.com/api/v1/media/subtitles/6720a1b2c3d4e5f607182950.vtt?expires=1774574694&sig=...",
"default": true
}
],
"has_subtitles": true,
"audio_tracks": [
{"lang": "pt", "label": "Português", "audio_description": false},
{"lang": "pt", "label": "Português (audiodescrição)", "audio_description": true}
],
"has_audio_description": true,
"thumbnails_vtt": "https://fdplay-api.infraifd.com/api/v1/media/seek/6720a1b2c3d4e5f607182950.vtt?expires=1774574694&sig=...",
"intro_start": 12,
"intro_end": 95,
"credits_start": 2710
}
| 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 |
subtitles[] |
array |
Legendas cadastradas no painel (GridFS), cada uma com link assinado do mesmo prazo do HLS. [] se nao ha. Legendas embutidas no HLS nao aparecem aqui |
subtitles[].lang / label / default |
string / string / bool |
Idioma (pt, en, pt-BR), rotulo do menu e se vem selecionada |
subtitles[].url |
string |
GET /media/subtitles/{id}.vtt?expires=&sig= — ver legendas |
has_subtitles |
bool |
true se subtitles nao esta vazio |
audio_tracks[] |
array |
Faixa principal e, se o video tem, a de audiodescricao (audio_description: true). A faixa vem dentro do HLS (EXT-X-MEDIA); a API so nomeia |
has_audio_description |
bool |
O painel marcou audiodescricao no video |
thumbnails_vtt |
string \| null |
VTT de miniaturas da barra de progresso — ver miniaturas. null se o video ainda nao as tem: esconder a previa |
intro_start / intro_end |
int \| null |
Segundos da abertura ("Pular abertura") |
credits_start |
int \| null |
Segundo em que comecam os creditos (contagem do proximo episodio) |
Campos novos sao aditivos (ADR-0177)
stream_url e expires_at nao mudaram; app que ignora o resto continua funcionando.
expires_at sai com +00:00 (nao Z), como antes.
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 |
| 403 | CONTENT_NOT_YET_AVAILABLE |
availability_start_date no futuro (relogio do servidor, UTC) |
"Estreia em {data}" a partir de available_at |
| 403 | CONTENT_NO_LONGER_AVAILABLE |
availability_end_date no passado |
Tela de indisponivel |
| 404 | NOT_FOUND |
Video nao existe, esta desligado (enable: false) ou nao e Bunny Stream |
Usar iframe como fallback |
| 500 | EXTERNAL_SERVICE_ERROR |
Bunny CDN nao configurado no backend | Usar iframe como fallback |
Janela de exibicao (ADR-0190)¶
O stream-url so entrega dentro de availability_start_date..availability_end_date (data nula = sem limite). Fora dela nenhum link sai: nem o HLS, nem os de legenda e miniaturas, que nascem aqui. A estreia vale pelo relogio do servidor — adiantar a data do aparelho nao adianta nada. Datas gravadas sem fuso valem como UTC.
{"error": {"code": "CONTENT_NOT_YET_AVAILABLE",
"message": "Este conteúdo estreia em 30/10/2026.",
"available_at": "2026-10-30T04:00:00Z",
"timestamp": "2026-10-28T12:00:00Z"}}
available_ate UTC; a data namessagee a de Brasilia.- Admin passa (previa do painel), inclusive com
enable: false. Assinante nao. - Mesma regra no download e na renovacao (abaixo). O trailer, as listas (
GET /videos, "Em breve") e a pagina de compartilhamento nao mudam. - Legenda, miniaturas e sprites ja emitidos continuam validos ate o
expires(4 h): a data nao e conferida de novo nessas rotas.
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)
Trailer (ADR-0177)¶
| Rota | Auth | Descricao |
|---|---|---|
GET /api/v1/videos/{video_id}/trailer-url |
Bearer (sem exigir assinatura) | Link HLS assinado do trailer_video_id do video |
GET /api/v1/seasons/{season_id}/trailer-url |
Bearer (sem exigir assinatura) | Idem, para a serie (temporada); 404 enquanto a série não tem episódio publicado |
O trailer toca na pagina do titulo antes de assinar, por isso so exige login. Por isso
tambem o trailer_video_id nunca pode ser o proprio video (o token do Bunny cobre a pasta
do video): POST/PUT /videos recusam com 422. O valor aceito e a URL do iframe do Bunny ou o GUID.
{"stream_url": "https://vz-8ac53877-fe8.b-cdn.net/bcdn_token=...&token_path=/<guid-do-trailer>/playlist.m3u8",
"expires_at": "2026-10-23T20:00:00+00:00"}
| HTTP | Quando |
|---|---|
| 401 | Sem login |
| 404 | Titulo inexistente ou desligado (enable=false, no video), ou sem trailer (field: trailer_video_id) |
| 500 | EXTERNAL_SERVICE_ERROR: Bunny CDN nao configurado |
Legendas e imagens assinadas¶
Cadastro (admin, videos com token de admin)¶
| Rota | Descricao |
|---|---|
POST /api/v1/videos/{video_id}/subtitles |
Multipart: file (WebVTT — comeca com WEBVTT, ate 2 MB), lang (2–10), label (1–40), default (bool, padrao false). Substitui a legenda do mesmo lang; so uma fica default. Resposta: {"subtitles": [{lang, label, file_id, default}]} |
DELETE /api/v1/videos/{video_id}/subtitles/{lang} |
204. 404 se o video ou a legenda nao existe |
Erros do upload: 404 video inexistente, 413 passa de 2 MB, 415 nao e WebVTT.
subtitles e somente leitura em POST/PUT /videos (so estas rotas gravam).
Leitura (link assinado)¶
| Rota | Auth | Descricao |
|---|---|---|
GET /api/v1/media/subtitles/{file_id}.vtt?expires=&sig= |
Link assinado | text/vtt. expires e sig vem do stream-url (4 h, HMAC). Vencido ou adulterado: 403. O player busca sem header de autenticacao |
GET /api/v1/media/images/{file_id}?expires=&sig= |
Link assinado | Imagem do GridFS. O link vem de quem o gerou (push, e-mail, previa de compartilhamento). Aceita w/q/fmt (entrega de imagens). Vencido ou adulterado: 403 |
Miniaturas da barra de tempo (ADR-0188)¶
thumbnails_vtt e um WebVTT (padrao Netflix/Video.js/Plyr): um bloco a cada 10 s,
do inicio ao fim do video, cada um apontando para um recorte de um sprite JPEG.
WEBVTT
00:00:00.000 --> 00:00:10.000
https://fdplay-api.infraifd.com/api/v1/media/seek-sprites/6720a1b2c3d4e5f607182951.jpg?expires=...&sig=...#xywh=0,0,160,90
00:00:10.000 --> 00:00:20.000
https://fdplay-api.infraifd.com/api/v1/media/seek-sprites/6720a1b2c3d4e5f607182951.jpg?expires=...&sig=...#xywh=160,0,160,90
| Item | Valor |
|---|---|
| Miniatura | 160x90 (16:9; com tarja se o video nao for) |
| Sprite | JPEG, grade 10x10 (100 miniaturas, 1600x900); video longo tem varios |
| URL do sprite | Absoluta e ja assinada, mesmo prazo do .vtt (4 h). Nao resolver relativo ao .vtt: a query se perderia |
| CORS | Access-Control-Allow-Origin: * no .vtt e nos sprites |
| Sem miniaturas | thumbnails_vtt: null (video ainda nao processado): esconder a previa. A API nunca devolve link que da 404 |
Vencido ou adulterado: 403. Baixar o .vtt uma vez por sessao do player e reaproveitar
os sprites (vem com Cache-Control: private).
Download offline (ADR-0180)¶
Baixar para assistir sem rede. Ate 10 downloads ativos por conta; a licenca vale 30 dias e e renovada ao reconectar. Baixar e renovar exigem assinatura ativa.
| Rota | Auth | Descricao |
|---|---|---|
POST /api/v1/videos/{video_id}/download-license |
Bearer + assinatura | Registra o download e devolve o MP4 (assinado, 24 h) + a licenca. O mesmo video no mesmo aparelho reaproveita a vaga |
GET /api/v1/me/downloads |
Bearer | {downloads: [...], max_downloads: 10} — so os que ainda valem |
DELETE /api/v1/me/downloads/{id} |
Bearer | 204, libera a vaga. 404 se nao e da conta |
POST /api/v1/me/downloads/{id}/renew |
Bearer + assinatura | Mais 30 dias a partir de agora. Sem assinatura ou titulo que saiu do ar (availability_end_date): 403 CONTENT_NO_LONGER_AVAILABLE. Vencido, apagado ou desligado (enable=false): 404 (baixar de novo, se houver vaga) |
{
"id": "6720a1b2c3d4e5f607182960",
"video_id": "696d28f79cc769e4fbeb65dd",
"created_at": "2026-10-23T12:00:00Z",
"expires_at": "2026-11-22T12:00:00Z",
"url": "https://vz-8ac53877-fe8.b-cdn.net/bcdn_token=...&token_path=/<guid>/play_720p.mp4",
"url_expires_at": "2026-10-24T12:00:00+00:00"
}
expires_at e o fim da licenca (o app apaga o arquivo se nao renovar antes); url e so
o caminho para baixar. Em GET /me/downloads e no renew o objeto vem sem url/url_expires_at.
409 DOWNLOAD_LIMIT_REACHED (11o download):
{"detail": {"error": {"code": "DOWNLOAD_LIMIT_REACHED",
"message": "Você já tem 10 downloads. Apague um para baixar outro.",
"max_downloads": 10,
"downloads": [{"id": "...", "video_id": "...", "created_at": "...", "expires_at": "..."}],
"timestamp": "2026-10-23T12:00:00Z"}}}
| HTTP | Quando |
|---|---|
| 403 | Sem assinatura ativa; ou CONTENT_NOT_YET_AVAILABLE / CONTENT_NO_LONGER_AVAILABLE (janela de exibicao, ADR-0190) |
| 404 | Video inexistente, desligado ou sem arquivo no Bunny |
| 500 | EXTERNAL_SERVICE_ERROR: bunny.download_file nao configurado (nada e registrado) |
Sem DRM
O arquivo baixado pode ser copiado; a licenca e um controle do app, nao do arquivo. Exige ligar o fallback MP4 na biblioteca do Bunny.
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 |
Os tres agem no perfil do header opcional X-Profile-Id (sem ele, o perfil padrao; de outra conta: 403). 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.