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.

.cs C# Actualizado el Ver en Markdown

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. 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:

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 explica el parámetro de consulta api_key como alternativa al encabezado.

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

Paso 2: crea el proyecto

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.

Shell
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup

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.

C#
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.

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 lista el resto de los campos.

C#
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);

Paso 5: gestiona errores y límites de solicitudes

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

EstadoSignificadoQué hace la app
401Clave ausente, incorrecta o suspendidaImprime un mensaje y se detiene, un reintento no ayuda.
402El periodo del plan ha terminadoImprime un mensaje y se detiene. Renueva en precios.
404No hay registro para ese idLo informa y sale.
429Superaste tus solicitudes por minutoEspera Retry-After segundos, con un tope de 30, y reintenta como máximo dos veces.
500 o 504Fallo del lado del servidorInforma del estado y ejecuta el comando de nuevo más tarde.

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 y errores para ver las listas completas.

C#
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
}

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.

C#
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.

C#
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.

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 para ver la lista de tamaños.

Shell
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

Próximos pasos

  • Busca con un año o con paginación en búsqueda y filtra listas con discover.
  • Muestra todas las puntuaciones y sus reglas para null con puntuaciones.
  • Mueve el código de consola a un servicio de ASP.NET Core con AddHttpClient e IMemoryCache.
  • Compara la misma tarea en Java o Go.