API de Vídeos¶
A API de vídeos gerencia o conteúdo principal da plataforma de streaming: metadados de vídeos, thumbnails, publicação e controle de acesso por assinatura.
🔐 Autenticação¶
Todos os endpoints de vídeos exigem autenticação JWT.
Middleware de Assinatura¶
⚠️ IMPORTANTE: Usuários do tipo customer precisam de assinatura ativa para acessar vídeos.
# Middleware aplicado automaticamente em GET /api/v1/videos
async def verify_subscription(auth: TokenData) -> TokenData:
"""
Valida assinatura ativa antes de permitir acesso aos vídeos.
- Admin users: bypass (sem validação)
- Customer users: require current_subscription_id not null + status='active'
"""
Fluxo de Validação:
sequenceDiagram
participant Client
participant API
participant Auth
participant Middleware
participant DB
Client->>API: GET /api/v1/videos<br/>Authorization: Bearer <token>
API->>Auth: Validar JWT
Auth->>Middleware: verify_subscription(auth)
alt User Type = Admin
Middleware-->>API: ✅ Bypass (admin)
else User Type = Customer
Middleware->>DB: Buscar user.current_subscription_id
alt current_subscription_id exists
Middleware->>DB: Buscar subscription (status)
alt subscription.status = 'active'
Middleware-->>API: ✅ Autorizado
else status != 'active'
Middleware-->>Client: ❌ 403 Subscription not active
end
else current_subscription_id is null
Middleware-->>Client: ❌ 403 No active subscription
end
end
API->>DB: Buscar vídeos
DB-->>API: Lista de vídeos
API-->>Client: 200 OK + docs
📋 Endpoints¶
Streaming HLS: Para obter URLs assinadas de streaming direto (player customizado), ver API de Streaming HLS.
1. GET /api/v1/videos - Listar Vídeos¶
Buscar vídeos publicados com filtros, paginação e ordenação.
Headers:
Query Parameters:
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
_id |
OID |
- | ID específico do vídeo |
query |
str (JSON) |
null |
Query MongoDB customizada |
sort |
str (JSON) |
{"register_update_date":-1} |
Ordenação |
qty_docs_page |
int |
10 |
Documentos por página |
current_page |
int |
0 |
Página atual (zero-indexed) |
docs_range |
str (tuple) |
"(0, 0)" |
Range customizado |
Exemplos de Query:
# Buscar vídeo por ID
GET /api/v1/videos?_id=507f1f77bcf86cd799439011
# Buscar vídeos publicados (enable=true)
GET /api/v1/videos?query={"enable":true}
# Buscar por categoria
GET /api/v1/videos?query={"category_id":"507f1f77bcf86cd799439012"}
# Buscar por tag
GET /api/v1/videos?query={"tags":"tutorial"}
# Buscar destaques principais
GET /api/v1/videos?query={"main_highlight":true}
# Buscar vídeos recentes (últimos 30 dias)
GET /api/v1/videos?query={"release_date":{"$gte":"2026-01-01T00:00:00Z"}}
# Combinar filtros: publicados + categoria + tag
GET /api/v1/videos?query={"enable":true,"category_id":"507f...","tags":"tutorial"}
# Paginação: página 2, 20 itens por página
GET /api/v1/videos?current_page=1&qty_docs_page=20
# Ordenar por data de release (mais recentes primeiro)
GET /api/v1/videos?sort={"release_date":-1}
Response (200 OK):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"title": "Introdução ao FastAPI",
"slug": "intro-fastapi",
"video_id": "https://youtube.com/watch?v=xyz",
"description": "Aprenda os fundamentos do FastAPI para criar APIs modernas",
"category_id": ["507f1f77bcf86cd799439012"],
"category_name": ["Python"],
"tags_id": ["507f1f77bcf86cd799439021"],
"tags_name": ["FastAPI"],
"tags": ["tutorial", "python", "fastapi"],
"author": "John Doe",
"duration": 45,
"thumbnail_horizontal": "507f1f77bcf86cd799439013",
"thumbnail_horizontal_name_less": "507f1f77bcf86cd799439015",
"thumbnail_vertical": "507f1f77bcf86cd799439014",
"thumbnail_vertical_name_less": "507f1f77bcf86cd799439016",
"release_date": "2026-01-01T00:00:00Z",
"expiration_date": null,
"enable": true,
"main_highlight": false,
"section_highlight": false,
"is_show_in_slide_carousel": false,
"season_id": "69ac725620019bcb4cce5f4a",
"season_name": "No Divã - Temporada 1",
"season_number": 1,
"episode_number": 3,
"video_gridfs_id": null,
"document_created_by_id": "507f1f77bcf86cd799439015",
"register_date": "2025-12-01T10:00:00Z",
"register_update_date": "2026-01-08T14:30:00Z"
}
],
"links": [
{"link_type": "GET", "rel": "self", "href": "/api/v1/videos?current_page=0&qty_docs_page=10"},
{"link_type": "GET", "rel": "get document", "href": "/api/v1/videos/{id}"},
{"link_type": "DELETE", "rel": "delete document", "href": "/api/v1/videos/{id}"},
{"link_type": "POST", "rel": "insert document", "href": "/api/v1/videos"},
{"link_type": "PUT", "rel": "update document", "href": "/api/v1/videos/{id}"}
],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 10,
"qty_of_pages": 15,
"qty_total_docs": 150
}
}
Response (403 Forbidden - Sem Assinatura Ativa):
2. POST /api/v1/videos - Criar Vídeo¶
Criar novos vídeos na plataforma (admin only).
Headers:
Request Body:
[
{
"title": "Introdução ao FastAPI",
"slug": "intro-fastapi",
"video_id": "https://youtube.com/watch?v=xyz",
"description": "Aprenda os fundamentos do FastAPI",
"category_id": ["507f1f77bcf86cd799439012"],
"tags": ["tutorial", "python"],
"author": "John Doe",
"duration": 45,
"thumbnail_horizontal": "507f1f77bcf86cd799439013",
"thumbnail_vertical": "507f1f77bcf86cd799439014",
"release_date": "2026-01-01T00:00:00Z",
"enable": true
}
]
Campos Obrigatórios:
| Campo | Tipo | Descrição |
|---|---|---|
title |
str |
Titulo do video (1-200 chars) |
slug |
str |
URL-friendly identifier (unico, lowercase + hifens) |
video_id |
str (URL) |
URL do video (YouTube, Vimeo, etc.) |
duration |
int |
Duracao em minutos (>= 0) |
release_date |
datetime |
Data de publicacao (ISO 8601) |
Campos Opcionais:
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
description |
str |
"" |
Descricao detalhada |
category_id |
list[OID] |
[] |
IDs de categorias |
tags_id |
list[OID] |
[] |
IDs das tags (referencias) |
tags |
list[str] |
[] |
Tags como strings (busca/compatibilidade) |
author |
str |
"" |
Nome do autor/instrutor |
thumbnail_horizontal |
OID |
null |
GridFS file ID (16:9). URL: GET /api/v1/archive-records/{id} |
thumbnail_horizontal_name_less |
OID |
null |
GridFS file ID (16:9) sem nome sobreposto |
thumbnail_vertical |
OID |
null |
GridFS file ID (9:16). URL: GET /api/v1/archive-records/{id} |
thumbnail_vertical_name_less |
OID |
null |
GridFS file ID (9:16) sem nome sobreposto |
Thumbnails — Como montar a URL
Os campos thumbnail_horizontal e thumbnail_vertical contem apenas o ObjectId do GridFS.
Opção 1 — Endpoint público com archive_token (recomendado para Image.network, <img>):
archive_token é retornado no login. Não requer header Authorization.
Opção 2 — Endpoint autenticado com ?token= (quando archive_token indisponível):
Exemplo (opção 2):
https://fdplay-api.infraifd.com/api/v1/archive-records/69bea66a4a2410cd09d8c33b?token=eyJ...
Os campos thumbnail_url_horizontal e thumbnail_url_vertical (URLs externas Supabase) foram removidos.
Todas as thumbnails sao servidas via GridFS.
| expiration_date | datetime | null | Data de expiracao (ISO 8601) |
| enable | bool | true | Video publicado/visivel |
| main_highlight | bool | false | Destaque principal (homepage) |
| section_highlight | bool | false | Destaque na secao de categoria |
| is_show_in_slide_carousel | bool | false | Indica se o frontend deve mostrar o video no slide carousel |
| season_id | OID | null | ID da temporada (ref. collection seasons) |
| season_name | str | null | Nome da temporada (via agregacao) |
| season_number | int | null | Numero da temporada (1, 2, 3...) |
| episode_number | int | null | Numero do episodio (>= 1) |
| video_gridfs_id | OID | null | ID do video no GridFS (storage interno) |
| legacy_id | str | null | UUID do sistema legado |
Campos Auto-Gerados:
# Gerados automaticamente pelo sistema:
_id: ObjectId # MongoDB ID
created_by: OID # User ID (do token JWT)
register_date: datetime # Timestamp de criação
register_update_date: datetime # Timestamp de última atualização
Response (200 OK):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"title": "Introdução ao FastAPI",
"slug": "intro-fastapi",
...
}
],
"links": [
{"link_type": "GET", "rel": "self", "href": "/api/v1/videos"},
{"link_type": "POST", "rel": "insert document", "href": "/api/v1/videos"}
],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
3. PUT /api/v1/videos - Atualizar Vídeo¶
Atualizar vídeos existentes (admin only).
Headers:
Request Body:
[
{
"_id": "507f1f77bcf86cd799439011",
"title": "Introdução ao FastAPI - Atualizado",
"enable": false
}
]
⚠️ Campo _id obrigatório para identificar o documento a atualizar.
Atualização Parcial:
Apenas campos fornecidos são atualizados. Exemplo:
Response (200 OK):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"title": "Introdução ao FastAPI - Atualizado",
"enable": false,
"register_update_date": "2026-01-08T15:00:00Z" // Atualizado automaticamente
}
],
"links": [
{"link_type": "GET", "rel": "self", "href": "/api/v1/videos"},
{"link_type": "POST", "rel": "insert document", "href": "/api/v1/videos"}
],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
4. DELETE /api/v1/videos - Deletar Vídeo¶
Remover vídeos do banco de dados (admin only).
Headers:
Opção 1: Deletar por ID (Query String)
Opção 2: Deletar múltiplos (Request Body)
⚠️ ATENÇÃO: Deleção é permanente. Thumbnails no GridFS devem ser deletados separadamente via endpoints de arquivos.
Response (200 OK):
{
"docs": [],
"msg": "2 documents deleted successfully",
"links": [
{"link_type": "GET", "rel": "self", "href": "/api/v1/videos"},
{"link_type": "POST", "rel": "insert document", "href": "/api/v1/videos"}
],
"pagination": {
"current_page": 0,
"qty_docs_page": 0,
"qty_of_pages": 0,
"qty_total_docs": 0
}
}
🎨 Gerenciamento de Thumbnails (GridFS)¶
Thumbnails são armazenados no GridFS (MongoDB file storage) e referenciados nos campos:
thumbnail_horizontal(OID) - Thumbnail landscape (16:9)thumbnail_horizontal_name_less(OID) - Thumbnail landscape sem nome (16:9)thumbnail_vertical(OID) - Thumbnail portrait (9:16)thumbnail_vertical_name_less(OID) - Thumbnail portrait sem nome (9:16)
Fluxo de Upload:
sequenceDiagram
participant Admin
participant API
participant GridFS
participant VideosDB
Admin->>API: POST /api/v1/upload-file/archive-records/{id}<br/>(multipart/form-data)
API->>GridFS: Salvar arquivo
GridFS-->>API: file_id (OID)
API-->>Admin: 200 OK {file_id: "507f..."}
Admin->>API: POST /api/v1/videos<br/>{"thumbnail_horizontal": "507f..."}
API->>VideosDB: Salvar vídeo
VideosDB-->>API: Vídeo criado
API-->>Admin: 200 OK
Endpoints Relacionados:
# Upload de thumbnail
POST /api/v1/upload-file/archive-records/{id}
Content-Type: multipart/form-data
file: <binary>
# Response:
{
"file_id": "507f1f77bcf86cd799439013",
"filename": "thumbnail.jpg",
"content_type": "image/jpeg",
"size": 245678
}
# Download de thumbnail
GET /api/v1/archive-records/{file_id}
# Deletar thumbnail
DELETE /api/v1/upload-file/archive-records/{id}/{file_id}
🔍 Buscas Avançadas¶
Exemplos de Queries MongoDB¶
Buscar vídeos por múltiplas tags (OR):
Buscar vídeos publicados após data específica:
Buscar vídeos de categoria + tag + publicados:
Buscar vídeos por range de duração (30-60 minutos):
Buscar vídeos por autor:
🎯 Fluxo Frontend: Listagem de Vídeos¶
Exemplo de implementação Flutter/Dart:
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Buscar vídeos publicados (homepage)
Future<VideoListResponse> fetchPublishedVideos({int page = 0}) async {
final token = prefs.getString('access_token');
final params = {
'query': jsonEncode({'enable': true}),
'sort': jsonEncode({'release_date': -1}),
'current_page': page.toString(),
'qty_docs_page': '12',
};
final uri = Uri.parse('${AppConfig.baseUrl}/videos')
.replace(queryParameters: params);
final response = await http.get(
uri,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 403) {
// Redirecionar para página de assinatura
throw Exception('Subscription required');
}
if (response.statusCode != 200) {
throw Exception('HTTP error! status: ${response.statusCode}');
}
return VideoListResponse.fromJson(jsonDecode(response.body));
}
/// Buscar vídeos por categoria
Future<VideoListResponse> fetchVideosByCategory(String categoryId) async {
final token = prefs.getString('access_token');
final params = {
'query': jsonEncode({
'enable': true,
'category_id': categoryId,
}),
'sort': jsonEncode({'release_date': -1}),
};
final uri = Uri.parse('${AppConfig.baseUrl}/videos')
.replace(queryParameters: params);
final response = await http.get(
uri,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
);
return VideoListResponse.fromJson(jsonDecode(response.body));
}
/// Buscar vídeo específico por slug
Future<Video?> fetchVideoBySlug(String slug) async {
final token = prefs.getString('access_token');
final params = {
'query': jsonEncode({'slug': slug}),
};
final uri = Uri.parse('${AppConfig.baseUrl}/videos')
.replace(queryParameters: params);
final response = await http.get(
uri,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
);
final data = VideoListResponse.fromJson(jsonDecode(response.body));
return data.docs.isNotEmpty ? data.docs[0] : null;
}
Dart Models:
class Video {
final String id;
final String title;
final String slug;
final String videoId;
final String? description;
final List<String>? categoryId;
final List<String>? categoryName;
final List<String>? tagsId;
final List<String>? tagsName;
final List<String>? tags;
final String? author;
final int duration;
final String? thumbnailHorizontal;
final String? thumbnailHorizontalNameLess;
final String? thumbnailVertical;
final String? thumbnailVerticalNameLess;
final String releaseDate;
final String? expirationDate;
final bool enable;
final bool mainHighlight;
final bool sectionHighlight;
final bool isShowInSlideCarousel;
final String? seasonId;
final String? seasonName;
final int? seasonNumber;
final int? episodeNumber;
final String? videoGridfsId;
final String? documentCreatedById;
final String? registerDate;
final String? registerUpdateDate;
Video({
required this.id,
required this.title,
required this.slug,
required this.videoId,
this.description,
this.categoryId,
this.categoryName,
this.tagsId,
this.tagsName,
this.tags,
this.author,
required this.duration,
this.thumbnailHorizontal,
this.thumbnailHorizontalNameLess,
this.thumbnailVertical,
this.thumbnailVerticalNameLess,
required this.releaseDate,
this.expirationDate,
required this.enable,
this.mainHighlight = false,
this.sectionHighlight = false,
this.isShowInSlideCarousel = false,
this.seasonId,
this.seasonName,
this.seasonNumber,
this.episodeNumber,
this.videoGridfsId,
this.documentCreatedById,
this.registerDate,
this.registerUpdateDate,
});
factory Video.fromJson(Map<String, dynamic> json) {
return Video(
id: json['_id'],
title: json['title'],
slug: json['slug'],
videoId: json['video_id'],
description: json['description'],
categoryId: (json['category_id'] as List?)?.cast<String>(),
categoryName: (json['category_name'] as List?)?.cast<String>(),
tagsId: (json['tags_id'] as List?)?.cast<String>(),
tagsName: (json['tags_name'] as List?)?.cast<String>(),
tags: (json['tags'] as List?)?.cast<String>(),
author: json['author'],
duration: json['duration'] ?? 0,
thumbnailHorizontal: json['thumbnail_horizontal'],
thumbnailHorizontalNameLess: json['thumbnail_horizontal_name_less'],
thumbnailVertical: json['thumbnail_vertical'],
thumbnailVerticalNameLess: json['thumbnail_vertical_name_less'],
releaseDate: json['release_date'],
expirationDate: json['expiration_date'],
enable: json['enable'] ?? false,
mainHighlight: json['main_highlight'] ?? false,
sectionHighlight: json['section_highlight'] ?? false,
isShowInSlideCarousel: json['is_show_in_slide_carousel'] ?? false,
seasonId: json['season_id'],
seasonName: json['season_name'],
seasonNumber: json['season_number'],
episodeNumber: json['episode_number'],
videoGridfsId: json['video_gridfs_id'],
documentCreatedById: json['document_created_by_id'],
registerDate: json['register_date'],
registerUpdateDate: json['register_update_date'],
);
}
}
class VideoListResponse {
final List<Video> docs;
final Map<String, dynamic> pagination;
final List<dynamic> links;
final String msg;
VideoListResponse({
required this.docs,
required this.pagination,
required this.links,
required this.msg,
});
int get currentPage => pagination['current_page'] ?? 0;
int get qtyDocsPage => pagination['qty_docs_page'] ?? 0;
int get qtyOfPages => pagination['qty_of_pages'] ?? 0;
int get qtyTotalDocs => pagination['qty_total_docs'] ?? 0;
factory VideoListResponse.fromJson(Map<String, dynamic> json) {
return VideoListResponse(
docs: (json['docs'] as List)
.map((e) => Video.fromJson(e))
.toList(),
pagination: json['pagination'] ?? {},
links: json['links'] ?? [],
msg: json['msg'] ?? '',
);
}
}
⚠️ Tratamento de Erros¶
401 Unauthorized¶
Causa: Token JWT inválido, expirado ou ausente.
Solução: Fazer login novamente via POST /api/v1/token.
403 Forbidden¶
Causa: Customer sem assinatura ativa tentando acessar vídeos.
Solução: Redirecionar para página de assinatura (/api/v1/plans).
404 Not Found¶
Causa: ID ou query não encontrou documentos.
422 Unprocessable Entity¶
{
"detail": [
{
"loc": ["body", 0, "slug"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
Causa: Validação Pydantic falhou (campo obrigatório ausente, tipo incorreto, etc.).
📊 Modelo de Dados¶
Schema MongoDB (Video Entity):
class Video(DocBase):
"""
Entidade de video para plataforma de streaming.
"""
title: str # Titulo do video (1-200 chars)
slug: str # URL-friendly identifier (unico)
video_id: HttpUrl # URL do video (YouTube, Vimeo, etc.)
description: str = '' # Descricao detalhada
category_id: list[OID] = [] # IDs das categorias
category_name: list[str] | None = None # Nomes das categorias (via aggregation)
tags_id: list[OID] = [] # IDs das tags (referencias)
tags_name: list[str] | None = None # Nomes das tags (via aggregation)
tags: list[str] = [] # Tags como strings (busca/compatibilidade)
author: str = '' # Autor/instrutor
duration: int # Duracao em minutos (>= 0)
thumbnail_horizontal: OID | None = None # GridFS file ID (16:9)
thumbnail_horizontal_name_less: OID | None = None # GridFS file ID (16:9) sem nome
thumbnail_vertical: OID | None = None # GridFS file ID (9:16)
thumbnail_vertical_name_less: OID | None = None # GridFS file ID (9:16) sem nome
release_date: datetime # Data de publicacao (ISO 8601)
expiration_date: datetime | None = None # Data de expiracao
enable: bool = True # Video publicado/visivel
main_highlight: bool = False # Destaque principal (homepage)
section_highlight: bool = False # Destaque na secao de categoria
is_show_in_slide_carousel: bool = False # Flag para exibicao no slide carousel
season_id: OID | None = None # ID da temporada (ref. seasons)
season_name: str | None = None # Nome da temporada (via agregacao)
season_number: int | None = None # Numero da temporada (1, 2, 3...)
episode_number: int | None = None # Numero do episodio (>= 1)
video_gridfs_id: OID | None = None # ID do video no GridFS (storage interno)
legacy_id: str | None = None # UUID do sistema legado
# Metadados auto-gerados (DocBase):
# _id: OID
# document_created_by_id: OID
# document_created_by_name: str
# register_date: datetime
# register_update_date: datetime
Índices MongoDB:
# Índices recomendados para performance:
db.videos.createIndex({ "slug": 1 }, { unique: true })
db.videos.createIndex({ "enable": 1 })
db.videos.createIndex({ "release_date": -1 })
db.videos.createIndex({ "category_id": 1 })
db.videos.createIndex({ "tags": 1 })
db.videos.createIndex({ "main_highlight": 1 })
📺 Modelo de Dados — Season¶
Temporadas agrupam vídeos episódicos (ex.: "No Divã - Temporada 1"). Endpoints CRUD em /api/v1/seasons. GET requer assinatura ativa (admin bypass; mesma regra de /videos); POST/PUT/DELETE são admin-only.
Schema MongoDB (Season Entity):
class Season(DocBase):
"""
Temporada para agrupar vídeos episódicos.
"""
name: str # Nome da temporada (1-200 chars, único)
description: str = '' # Descrição da temporada
index: int = 0 # Índice de ordenação (>= 0)
thumbnail_vertical: OID | None = None # GridFS file ID (9:16)
thumbnail_vertical_name_less: OID | None = None # GridFS file ID (9:16) sem nome
thumbnail_horizontal: OID | None = None # GridFS file ID (16:9)
thumbnail_horizontal_name_less: OID | None = None # GridFS file ID (16:9) sem nome
# Metadados auto-gerados (DocBase):
# _id: OID
# document_created_by_id: OID
# document_created_by_name: str
# register_date: datetime
# register_update_date: datetime
Campos:
| Campo | Tipo | Padrão | Descrição |
|---|---|---|---|
name |
str |
obrigatório | Nome da temporada (1-200 chars, unique) |
description |
str |
"" |
Descrição livre |
index |
int |
0 |
Índice de ordenação (>= 0). Frontend ordena temporadas por index ascendente |
thumbnail_vertical |
OID |
null |
GridFS file ID (9:16). URL: GET /api/v1/archive-records/{id} |
thumbnail_vertical_name_less |
OID |
null |
GridFS file ID (9:16) sem nome sobreposto |
thumbnail_horizontal |
OID |
null |
GridFS file ID (16:9). URL: GET /api/v1/archive-records/{id} |
thumbnail_horizontal_name_less |
OID |
null |
GridFS file ID (16:9) sem nome sobreposto |
Ordenação no frontend:
// Ordenar temporadas por index ascendente
seasons.sort((a, b) => (a['index'] ?? 0).compareTo(b['index'] ?? 0));
Exemplo POST /api/v1/seasons:
[
{
"name": "No Divã",
"description": "Histórias incríveis transformadas no consultório.",
"index": 0,
"thumbnail_vertical": null,
"thumbnail_horizontal": null
}
]
Nota: A entidade
Seriesfoi removida do domínio. Vídeos episódicos referenciamseason_iddiretamente, sem agrupador adicional.
🚀 Próximos Passos¶
- Implementar busca full-text via MongoDB Atlas Search
- Sistema de visualizações (analytics) - rastrear plays
- Sistema de favoritos - permitir customers salvarem vídeos
- Recomendações personalizadas - ML-based suggestions
- Preview de vídeos - clips de 30 segundos sem assinatura