# Tutorial de API de películas en C# para .NET 8

> Este tutorial crea una app de consola en .NET 8 que recibe el título de una película, la busca en amodb, carga la mejor coincidencia con sus puntuaciones e imprime una ficha corta. Usa solo HttpClient y System.Text.Json, lee la clave de AMODB_KEY, reintenta ante un 429 y guarda las respuestas en archivos. La preparación lleva unos 15 minutos.

## Qué vas a construir

Vas a construir `MovieLookup`, una app de consola que recibe un título por la línea de comandos, lo busca en amodb, carga la mejor coincidencia con sus puntuaciones e imprime una ficha corta con un enlace al póster. Necesitas el SDK de .NET 8, una clave de amodb y unos 15 minutos. El programa usa solo `HttpClient` y `System.Text.Json`, así que no hay paquetes NuGet que instalar.

## Paso 1: obtén una clave de API

Elige un plan en [precios](https://amodb.dev/es/pricing/). Los planes se diferencian solo en la velocidad, con 20, 40 o 120 solicitudes por minuto. Crea una clave en el panel de tu cuenta, una cadena de 32 caracteres hexadecimales en minúsculas. Expórtala y pruébala con una llamada:

```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"
```

Un `200` significa que la clave funciona. Un `401` significa que la clave es incorrecta o falta. En PowerShell define la variable con `$env:AMODB_KEY = "YOUR_API_KEY"`. La página de [autenticación](https://amodb.dev/es/authentication/) explica el parámetro de consulta `api_key` como alternativa al encabezado.

## Paso 2: crea el proyecto

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

La plantilla de .NET 8 activa los usings implícitos y los tipos de referencia que admiten null. Eso ya importa `System`, `System.IO`, `System.Linq` y `System.Net.Http`. Tú añades las demás líneas `using` en el archivo.

## Paso 3: envía la primera solicitud

Crea un solo `HttpClient` con una dirección base, un tiempo de espera de 10 segundos y el encabezado Bearer. Termina la dirección base con una barra y escribe las rutas relativas sin barra inicial. Una barra inicial reemplazaría la parte `/3` de la ruta 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);
```

## Paso 4: convierte el JSON en records

La respuesta usa nombres en snake case como `release_date`. Configura `JsonNamingPolicy.SnakeCaseLower`, que es nuevo en .NET 8, y escribe miembros en PascalCase. Los campos que no declares se ignoran.

```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);
```

El objeto `ratings` aparece porque la llamada de detalles añade `append_to_response=ratings`. Cada una de sus cinco fuentes es un objeto con `value` y `votes`, o `null` cuando falta la puntuación. `votes` también puede ser `null`: la respuesta real de Fight Club no trae número de votos para Kinopoisk, Metacritic ni Rotten Tomatoes. La página de [películas](https://amodb.dev/es/movies/) lista el resto de los campos.

## Paso 5: gestiona errores y límites de solicitudes

Envuelve la llamada en un bucle que reaccione al código de estado.

| Estado    | Significado                            | Qué hace la app                                                                       |
| --------- | -------------------------------------- | ------------------------------------------------------------------------------------- |
| 401       | Clave ausente, incorrecta o suspendida | Imprime un mensaje y se detiene, un reintento no ayuda.                               |
| 402       | El periodo del plan ha terminado       | Imprime un mensaje y se detiene. Renueva en [precios](https://amodb.dev/es/pricing/). |
| 404       | No hay registro para ese id            | Lo informa y sale.                                                                    |
| 429       | Superaste tus solicitudes por minuto   | Espera `Retry-After` segundos, con un tope de 30, y reintenta como máximo dos veces.  |
| 500 o 504 | Fallo del lado del servidor            | Informa del estado y ejecuta el comando de nuevo más 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
}
```

Los fallos de red aparecen como `HttpRequestException` y un tiempo de espera agotado aparece como `TaskCanceledException`. Captura ambos en el nivel superior. Cada respuesta con clave incluye además `X-RateLimit-Remaining`, así que un trabajo largo puede reducir el ritmo antes de llegar a un 429. Consulta [límites de solicitudes](https://amodb.dev/es/rate-limits/) y [errores](https://amodb.dev/es/errors/) para ver las listas completas.

## Paso 6: guarda las respuestas en disco

La comprobación del límite de solicitudes se ejecuta antes del caché perimetral, así que una respuesta servida desde el caché perimetral cuenta igualmente para tus solicitudes por minuto. Un caché dentro de tu propio programa es la única forma de ahorrar solicitudes. El caché perimetral guarda los detalles 6 horas y los resultados de búsqueda 10 minutos, así que la app usa las mismas duraciones. Un archivo de la carpeta temporal está vigente cuando su hora de escritura cae dentro del 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;
    }
}
```

## Paso 7: júntalo todo

Este es el `Program.cs` completo. Añade el método `Set` al caché y la clase `AmodbClient` que combina el caché, el bucle de reintentos y las opciones 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)!;
        }
    }
}
```

Ejecútalo con un título. Esta es la salida para el primer resultado de la búsqueda, la película 550. El número de votos crece con el tiempo, así que el tuyo variará un poco.

```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
```

La línea de TMDB muestra 8.4 porque el código imprime un decimal de 8.437 en `ratings.tmdb.value`. El host de pósteres `img.amodb.dev` no necesita clave, así que puedes pasar la URL del póster directamente a un navegador o a un control de imagen. Consulta [imágenes](https://amodb.dev/es/images-api/) para ver la lista de tamaños.

## Próximos pasos

- Busca con un año o con paginación en [búsqueda](https://amodb.dev/es/search/) y filtra listas con [discover](https://amodb.dev/es/discover/).
- Muestra todas las puntuaciones y sus reglas para null con [puntuaciones](https://amodb.dev/es/ratings/).
- Mueve el código de consola a un servicio de ASP.NET Core con `AddHttpClient` e `IMemoryCache`.
- Compara la misma tarea en [Java](https://amodb.dev/es/java-movie-api/) o [Go](https://amodb.dev/es/go-movie-api/).

## Preguntas y respuestas

### ¿Necesito un paquete NuGet para llamar a la API desde C#?

No. HttpClient y System.Text.Json vienen incluidos en .NET 8, así que este tutorial no añade paquetes. Envía la clave en un encabezado Authorization Bearer, o pásala como parámetro de consulta api_key si lo prefieres.

### ¿Cómo leo el encabezado Retry-After en .NET?

Usa response.Headers.RetryAfter. Cuando el servidor envía un número de segundos, su propiedad Delta contiene un TimeSpan. Espera ese tiempo, con un tope de 30 segundos, y reintenta como máximo dos veces antes de informar del 429.

### ¿Debo crear un HttpClient por cada solicitud?

No. Crea una sola instancia y reutilízala, porque cada instancia nueva abre conexiones nuevas. En ASP.NET Core registra un cliente tipado con AddHttpClient e inyéctalo. Una app de consola puede mantener una única instancia durante toda su ejecución.

### ¿Puedo reutilizar modelos de C# escritos para TMDB v3?

Sí. En los endpoints que ofrece amodb, las rutas, los parámetros y los campos JSON coinciden con TMDB v3, así que los modelos que ya escribiste se deserializan sin cambios. Apunta la dirección base a https://api.amodb.dev/3/ y usa tu clave de amodb.

### ¿Por qué la ficha imprime not known en una puntuación?

Una fuente de puntuación es null cuando amodb no tiene nota para ese título. Trata null como desconocido, nunca como cero, e imprime solo las fuentes presentes. Pueden aparecer campos nuevos con el tiempo, así que ignora los que no conozcas.

## Páginas relacionadas

- [Tutorial de API de películas en Java](https://amodb.dev/es/java-movie-api/): Crea una guía de episodios en Java con HttpClient y Jackson: lee una temporada, respeta Retry-After ante un 429 y usa caché de archivos. Java 17 o superior.
- [API de búsqueda](https://amodb.dev/es/search/): API de búsqueda con rutas y JSON de TMDB v3. Encuentra películas, series, personas, colecciones, productoras y palabras clave por texto, con filtros de año.
- [API de detalles de película](https://amodb.dev/es/movies/): API de detalles de película con rutas y JSON de TMDB v3. Obtén una película por id y añade créditos, puntuaciones y moods con append_to_response, en 14 idiomas.
- [Puntuaciones de películas y series](https://amodb.dev/es/ratings/): API de puntuaciones de películas: IMDb, Kinopoisk, Metacritic, Rotten Tomatoes y TMDB de un título en una sola llamada, con null si falta una fuente.
- [Autenticación](https://amodb.dev/es/authentication/): Cómo enviar tu clave de amodb: api_key en la consulta, un encabezado Bearer o apikey en llamadas OMDb. Formato, almacenamiento, rotación y errores 401.
- [Límites de solicitudes](https://amodb.dev/es/rate-limits/): amodb limita cada clave por solicitudes por minuto en una ventana deslizante de 60 segundos: 20, 40 o 120 según el plan. Lee los encabezados y gestiona el 429.

Actualizado: 2026-10-18. Source: https://amodb.dev/es/csharp-movie-api/
