Skip to content

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:

  1. playlist.m3u8 (master playlist — lista de qualidades)
  2. Sub-playlists por qualidade (360p/video.m3u8, 720p/video.m3u8, etc.)
  3. 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

GET /api/v1/videos/696d28f79cc769e4fbeb65dd/stream-url
Authorization: Bearer <jwt_token>
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_at e UTC; a data na message e 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).

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 StreamingService com getStreamUrl()
  • [ ] Implementar VideoPlayerScreen com player customizado + fallback iframe
  • [ ] Implementar dialog "Continuar assistindo?" (buscar watch-history antes de iniciar)
  • [ ] Implementar Timer.periodic para salvar progresso a cada 15s
  • [ ] Salvar posicao final no dispose() do player
  • [ ] Implementar renovacao de URL expirada (isExpired check)
  • [ ] Toggle useCustomPlayer para 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() retornar null (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_url ate expires_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.