# Tutorial de API de filmes em C# para .NET 8

> Este tutorial cria um app de console .NET 8 que recebe o título de um filme, busca no amodb, carrega o melhor resultado com as avaliações e imprime um cartão curto. Usa só HttpClient e System.Text.Json, lê a chave de AMODB_KEY, repete a requisição em caso de 429 e guarda as respostas em arquivos. Leva cerca de 15 minutos.

## O que você vai construir

Você vai construir o `MovieLookup`, um app de console que recebe um título na linha de comando, busca no amodb, carrega o melhor resultado com as avaliações e imprime um cartão curto com o link do pôster. Você precisa do SDK do .NET 8, de uma chave do amodb e de cerca de 15 minutos. O programa usa só `HttpClient` e `System.Text.Json`, então não há pacotes NuGet para instalar.

## Passo 1: obtenha uma chave de API

Escolha um plano em [preços](https://amodb.dev/pt/pricing/). Os planos diferem apenas na taxa, com 20, 40 ou 120 requisições por minuto. Crie uma chave no painel da sua conta, uma sequência de 32 caracteres hexadecimais minúsculos. Exporte-a e teste com uma chamada:

```bash
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $AMODB_KEY" \
  "https://api.amodb.dev/3/movie/550"
```

Um `200` significa que a chave funciona. Um `401` significa que a chave está errada ou ausente. No PowerShell, defina a variável com `$env:AMODB_KEY = "YOUR_API_KEY"`. A página de [autenticação](https://amodb.dev/pt/authentication/) explica o parâmetro de consulta `api_key` como alternativa ao cabeçalho.

## Passo 2: crie o projeto

```bash
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup
```

O template do .NET 8 ativa os usings implícitos e os tipos de referência anuláveis. Isso já importa `System`, `System.IO`, `System.Linq` e `System.Net.Http`. As outras linhas `using` você adiciona no arquivo.

## Passo 3: envie a primeira requisição

Crie um único `HttpClient` com endereço base, timeout de 10 segundos e o cabeçalho Bearer. Termine o endereço base com uma barra e escreva os caminhos relativos sem barra inicial. Uma barra inicial substituiria a parte `/3` do caminho base.

```csharp
using System.Net.Http.Headers;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.amodb.dev/3/"),
    Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AMODB_KEY"));

var response = await http.GetAsync("search/movie?query=fight%20club&language=en-US");
Console.WriteLine((int)response.StatusCode);
```

## Passo 4: converta o JSON em records

A resposta usa nomes em snake case, como `release_date`. Defina `JsonNamingPolicy.SnakeCaseLower`, que é novidade do .NET 8, e escreva os membros em PascalCase. Os campos que você não declarar são ignorados.

```csharp
record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
    string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);
```

O objeto `ratings` aparece porque a chamada de detalhes adiciona `append_to_response=ratings`. Cada uma das cinco fontes é um objeto com `value` e `votes`, ou `null` quando a nota não existe. `votes` também pode ser `null`: a resposta real de Fight Club não traz contagem de votos para Kinopoisk, Metacritic nem Rotten Tomatoes. A página de [filmes](https://amodb.dev/pt/movies/) lista todos os outros campos.

## Passo 5: trate erros e limites de requisições

Envolva a chamada em um loop que reage ao código de status.

| Status     | Significado                                   | O que o app faz                                                                    |
| ---------- | --------------------------------------------- | ---------------------------------------------------------------------------------- |
| 401        | Chave ausente, errada ou suspensa             | Mostra uma mensagem e para, repetir não ajuda.                                     |
| 402        | O período do plano acabou                     | Mostra uma mensagem e para. Renove em [preços](https://amodb.dev/pt/pricing/).     |
| 404        | Nenhum registro para esse id                  | Informa o erro e encerra.                                                          |
| 429        | Acima do seu limite de requisições por minuto | Espera `Retry-After` segundos, no máximo 30, e tenta de novo no máximo duas vezes. |
| 500 ou 504 | Falha no lado do servidor                     | Informa o status e pede para rodar o comando de novo mais tarde.                   |

```csharp
for (var attempt = 0; ; attempt++)
{
    using var response = await http.GetAsync(path);
    if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
    {
        var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
        await Task.Delay(TimeSpan.FromSeconds(seconds));
        continue;
    }
    if (response.StatusCode == HttpStatusCode.Unauthorized)
        throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
    // ...handle other statuses, then read the body
}
```

Falhas de rede aparecem como `HttpRequestException`, e um timeout aparece como `TaskCanceledException`. Capture as duas no nível superior. Toda resposta com chave também traz `X-RateLimit-Remaining`, então um trabalho longo pode desacelerar antes de chegar a um 429. Veja [limites de requisições](https://amodb.dev/pt/rate-limits/) e [erros](https://amodb.dev/pt/errors/) para as listas completas.

## Passo 6: guarde as respostas em cache no disco

A verificação do limite de requisições roda antes do cache de borda, então uma resposta servida pela borda ainda conta nas suas requisições por minuto. Um cache no seu próprio programa é a única forma de economizar requisições. A borda guarda detalhes por 6 horas e resultados de busca por 10 minutos, então o app usa os mesmos tempos. Um arquivo na pasta temporária está válido quando a data de escrita está dentro do TTL.

```csharp
class FileCache
{
    readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");

    string PathFor(string key) =>
        Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");

    public string? Get(string key, TimeSpan ttl)
    {
        var file = PathFor(key);
        return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
            ? File.ReadAllText(file)
            : null;
    }
}
```

## Passo 7: junte tudo

Este é o `Program.cs` completo. Ele adiciona o método `Set` ao cache e a classe `AmodbClient`, que combina o cache, o loop de repetição e as opções de JSON.

```csharp
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; // same number format on every machine

var key = Environment.GetEnvironmentVariable("AMODB_KEY");
if (string.IsNullOrWhiteSpace(key))
{
    Console.Error.WriteLine("Set AMODB_KEY first.");
    return 1;
}

var title = string.Join(' ', args).Trim();
if (title.Length == 0)
{
    Console.Error.WriteLine("Usage: dotnet run -- \"movie title\"");
    return 1;
}

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.amodb.dev/3/"),
    Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
var client = new AmodbClient(http, new FileCache());

try
{
    var search = await client.GetAsync<SearchResponse>(
        $"search/movie?query={Uri.EscapeDataString(title)}&language=en-US", TimeSpan.FromMinutes(10));
    var hit = search.Results.FirstOrDefault();
    if (hit is null)
    {
        Console.WriteLine($"No movie found for \"{title}\".");
        return 0;
    }

    var movie = await client.GetAsync<Movie>(
        $"movie/{hit.Id}?language=en-US&append_to_response=ratings", TimeSpan.FromHours(6));
    Print(movie);
    return 0;
}
catch (AmodbException ex)
{
    Console.Error.WriteLine(ex.Message);
    return 1;
}
catch (HttpRequestException ex)
{
    Console.Error.WriteLine($"Network error: {ex.Message}");
    return 1;
}
catch (TaskCanceledException)
{
    Console.Error.WriteLine("The request timed out after 10 seconds.");
    return 1;
}

static void Print(Movie m)
{
    var year = m.ReleaseDate is { Length: >= 4 } d ? d[..4] : "n/a";
    Console.WriteLine($"{m.Title} ({year}), {m.Runtime?.ToString() ?? "?"} min");
    if (!string.IsNullOrEmpty(m.Tagline)) Console.WriteLine(m.Tagline);
    Console.WriteLine($"IMDb: {Describe(m.Ratings?.Imdb)}");
    Console.WriteLine($"TMDB: {Describe(m.Ratings?.Tmdb)}");
    if (m.PosterPath is not null)
        Console.WriteLine($"Poster: https://img.amodb.dev/t/p/w342{m.PosterPath}");
}

static string Describe(Score? s) =>
    s is null ? "not known" : $"{s.Value:0.0}" + (s.Votes is { } v ? $" ({v:N0} votes)" : "");

record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
    string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);

class AmodbException : Exception
{
    public AmodbException(string message) : base(message) { }
}

class FileCache
{
    readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");

    string PathFor(string key) =>
        Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");

    public string? Get(string key, TimeSpan ttl)
    {
        var file = PathFor(key);
        return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
            ? File.ReadAllText(file)
            : null;
    }

    public void Set(string key, string json)
    {
        Directory.CreateDirectory(dir);
        File.WriteAllText(PathFor(key), json);
    }
}

class AmodbClient(HttpClient http, FileCache cache)
{
    static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    };

    public async Task<T> GetAsync<T>(string path, TimeSpan ttl)
    {
        var cached = cache.Get(path, ttl);
        if (cached is not null) return JsonSerializer.Deserialize<T>(cached, Options)!;

        for (var attempt = 0; ; attempt++)
        {
            using var response = await http.GetAsync(path);
            if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
            {
                var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
                await Task.Delay(TimeSpan.FromSeconds(seconds));
                continue;
            }
            if (response.StatusCode == HttpStatusCode.Unauthorized)
                throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
            if (!response.IsSuccessStatusCode)
                throw new AmodbException($"amodb returned status {(int)response.StatusCode}.");

            var body = await response.Content.ReadAsStringAsync();
            cache.Set(path, body);
            return JsonSerializer.Deserialize<T>(body, Options)!;
        }
    }
}
```

Execute-o com um título. Esta é a saída do primeiro resultado da busca, o filme 550. As contagens de votos crescem com o tempo, então as suas serão um pouco diferentes.

```bash
dotnet run -- "fight club"
# Fight Club (1999), 139 min
# Mischief. Mayhem. Soap.
# IMDb: 8.8 (2,669,989 votes)
# TMDB: 8.4 (33,039 votes)
# Poster: https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg
```

A linha do TMDB mostra 8.4 porque o código imprime uma casa decimal de 8.437 em `ratings.tmdb.value`. O host de pôsteres `img.amodb.dev` não precisa de chave, então você pode passar a URL do pôster direto para um navegador ou um controle de imagem. Veja [imagens](https://amodb.dev/pt/images-api/) para a lista de tamanhos.

## Próximos passos

- Busque com ano ou paginação em [busca](https://amodb.dev/pt/search/) e filtre listas com o endpoint [discover](https://amodb.dev/pt/discover/).
- Mostre todas as notas e as regras de null com [avaliações](https://amodb.dev/pt/ratings/).
- Mova o código do console para um serviço ASP.NET Core com `AddHttpClient` e `IMemoryCache`.
- Compare a mesma tarefa em [Java](https://amodb.dev/pt/java-movie-api/) ou [Go](https://amodb.dev/pt/go-movie-api/).

## Perguntas e respostas

### Preciso de um pacote NuGet para chamar a API em C#?

Não. HttpClient e System.Text.Json já vêm com o .NET 8, então este tutorial não adiciona pacotes. Envie a chave em um cabeçalho Authorization Bearer ou, se preferir, passe-a no parâmetro de consulta api_key.

### Como leio o cabeçalho Retry-After no .NET?

Use response.Headers.RetryAfter. Quando o servidor envia um número de segundos, a propriedade Delta guarda um TimeSpan. Espere esse tempo, limitado a 30 segundos, e tente de novo no máximo duas vezes antes de informar o 429.

### Devo criar um HttpClient para cada requisição?

Não. Crie uma instância e reutilize-a, porque cada nova instância abre conexões novas. No ASP.NET Core, registre um cliente tipado com AddHttpClient e injete-o. Um app de console pode manter uma única instância durante toda a execução.

### Posso reutilizar modelos C# escritos para o TMDB v3?

Sim. Nos endpoints que o amodb oferece, caminhos, parâmetros e campos JSON são os mesmos do TMDB v3, então os modelos que você já escreveu desserializam sem mudanças. Aponte o endereço base para https://api.amodb.dev/3/ e use sua chave do amodb.

### Por que o cartão imprime not known no lugar de uma nota?

Uma fonte de avaliação é null quando o amodb não tem nota para aquele título. Trate null como desconhecido, nunca como zero, e imprima só as fontes presentes. Novos campos podem surgir com o tempo, então ignore os campos que você não conhece.

## Páginas relacionadas

- [Tutorial de API de filmes em Java](https://amodb.dev/pt/java-movie-api/): Crie um guia de episódios em Java com HttpClient do JDK e records do Jackson: busque uma temporada, respeite Retry-After em 429 e use cache em arquivos.
- [API de busca](https://amodb.dev/pt/search/): API de busca com caminhos e JSON do TMDB v3. Encontre filmes, séries, pessoas, coleções, produtoras e palavras-chave por texto, com filtro de ano e paginação.
- [API de detalhes de filmes](https://amodb.dev/pt/movies/): API de detalhes de filmes com caminhos e JSON do TMDB v3. Busque um filme por id e junte créditos, avaliações e moods com append_to_response, em 14 idiomas.
- [Avaliações de filmes e séries](https://amodb.dev/pt/ratings/): API de avaliações de filmes com as notas do IMDb, Kinopoisk, Metacritic, Rotten Tomatoes e TMDB em uma só chamada, com null se a fonte for desconhecida.
- [Autenticação](https://amodb.dev/pt/authentication/): Como enviar sua chave do amodb: api_key na consulta, um cabeçalho Authorization Bearer ou apikey nas chamadas OMDb. Formato, armazenamento, rotação e erros 401.
- [Limites de requisições](https://amodb.dev/pt/rate-limits/): O amodb limita cada chave por requisições por minuto em uma janela de 60 segundos: 20, 40 ou 120 por plano. Leia os cabeçalhos e trate o 429 com Retry-After.

Atualizado: 2026-10-18. Source: https://amodb.dev/pt/csharp-movie-api/
