Návod na filmové API v C# pro .NET 8

Tento návod vytvoří konzolovou aplikaci .NET 8, která převezme název filmu, vyhledá ho v amodb, načte nejlepší shodu s hodnoceními a vypíše krátkou kartu. Používá jen HttpClient a System.Text.Json, čte klíč z AMODB_KEY, při 429 opakuje pokus a ukládá odpovědi do souborů. Příprava trvá asi 15 minut.

.cs C# Aktualizováno Zobrazit jako Markdown

Co vytvoříte

Vytvoříte MovieLookup, konzolovou aplikaci, která převezme název z příkazového řádku, vyhledá ho v amodb, načte nejlepší shodu s hodnoceními a vypíše krátkou kartu s odkazem na plakát. Potřebujete .NET 8 SDK, klíč amodb a asi 15 minut. Program používá jen HttpClient a System.Text.Json, takže neinstalujete žádné balíčky NuGet.

Krok 1: Získejte API klíč

Zvolte tarif na stránce ceník. Tarify se liší jen rychlostí, a to 20, 40 nebo 120 požadavků za minutu. Vytvořte klíč v panelu účtu, je to řetězec z 32 malých hexadecimálních znaků. Exportujte ho a vyzkoušejte jedním voláním:

200 znamená, že klíč funguje. 401 znamená, že klíč je nesprávný nebo chybí. V PowerShellu nastavte proměnnou pomocí $env:AMODB_KEY = "YOUR_API_KEY". Stránka autentizace popisuje parametr dotazu api_key jako alternativu k hlavičce.

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"

Krok 2: Vytvořte projekt

Šablona .NET 8 zapíná implicitní using a nullable referenční typy. Tím se už importují System, System.IO, System.Linq a System.Net.Http. Ostatní řádky using přidáte v souboru.

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

Krok 3: Odešlete první požadavek

Vytvořte jeden HttpClient se základní adresou, 10sekundovým timeoutem a hlavičkou Bearer. Základní adresu zakončete lomítkem a relativní cesty pište bez úvodního lomítka. Úvodní lomítko by nahradilo část /3 v základní cestě.

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

Krok 4: Převeďte JSON na záznamy

Odpověď používá názvy ve snake case, například release_date. Nastavte JsonNamingPolicy.SnakeCaseLower, které je nové v .NET 8, a píšte členy v PascalCase. Pole, která nedeklarujete, se ignorují.

Objekt ratings se objeví, protože volání detailů přidává append_to_response=ratings. Každý z jeho pěti zdrojů je objekt s value a votes, nebo null, když skóre chybí. votes může být také null: živá odpověď pro Fight Club nemá počet hlasů pro Kinopoisk, Metacritic ani Rotten Tomatoes. Všechna ostatní pole uvádí stránka filmy.

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

Krok 5: Ošetřete chyby a limity požadavků

Obalte volání smyčkou, která reaguje na stavový kód.

StavVýznamCo dělá aplikace
401Klíč chybí, je nesprávný nebo pozastavenýVypíše zprávu a skončí, opakování nepomůže.
402Platnost tarifu skončilaVypíše zprávu a skončí. Obnovte ho na stránce ceník.
404Pro toto ID neexistuje záznamNahlásí to a skončí.
429Překročili jste požadavky za minutuPočká Retry-After sekund, nejvýše 30, a opakuje pokus nejvýše dvakrát.
500 nebo 504Selhání na straně serveruNahlásí stav, příkaz spusťte znovu později.

Síťové chyby se projeví jako HttpRequestException a timeout jako TaskCanceledException. Obě zachyťte na nejvyšší úrovni. Každá odpověď s klíčem nese také X-RateLimit-Remaining, takže dlouhá úloha může zpomalit, než dosáhne 429. Úplné seznamy najdete na stránkách limity požadavků a chyby.

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
}

Krok 6: Ukládejte odpovědi na disk

Kontrola limitu proběhne před cache na edge, takže odpověď doručená z edge se do požadavků za minutu stále počítá. Jedinou cestou, jak požadavky ušetřit, je cache ve vašem vlastním programu. Edge drží detaily 6 hodin a výsledky hledání 10 minut, aplikace proto používá stejné doby platnosti. Soubor ve složce temp je čerstvý, když je čas jeho zápisu uvnitř 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;
    }
}

Krok 7: Složte to dohromady

Toto je kompletní Program.cs. K cache přidává metodu Set a třídu AmodbClient, která spojuje cache, smyčku opakování a nastavení 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)!;
        }
    }
}

Spusťte ho s názvem filmu. Toto je výstup pro první nalezený film, film 550. Počty hlasů se časem zvyšují, váš výsledek se proto mírně liší.

Řádek TMDB ukazuje 8.4, protože kód vypisuje jedno desetinné místo z 8.437 v ratings.tmdb.value. Hostitel plakátů img.amodb.dev nepotřebuje klíč, URL plakátu proto můžete předat přímo prohlížeči nebo ovládacímu prvku obrázku. Seznam velikostí viz obrázky.

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

Další kroky

  • Hledejte s rokem nebo stránkováním na stránce vyhledávání a filtrujte seznamy pomocí discover.
  • Zobrazte každé skóre a jeho pravidla pro null na stránce hodnocení.
  • Přesuňte konzolový kód do služby ASP.NET Core s AddHttpClient a IMemoryCache.
  • Porovnejte stejnou úlohu v Javě nebo v Go.