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"
}
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 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.