# Tutoriel API de films en C# pour .NET 8

> Ce tutoriel construit une application console .NET 8 qui prend un titre de film, le cherche sur amodb, charge le meilleur résultat avec ses notes et affiche une fiche courte. Elle n'utilise que HttpClient et System.Text.Json, lit la clé dans AMODB_KEY, réessaie sur 429 et met les réponses en cache. La mise en place prend environ 15 minutes.

## Ce que vous construisez

Vous construisez `MovieLookup`, une application console qui prend un titre sur la ligne de commande, le cherche sur amodb, charge le meilleur résultat avec ses notes et affiche une courte fiche avec un lien d'affiche. Il vous faut le SDK .NET 8, une clé amodb et environ 15 minutes. Le programme n'utilise que `HttpClient` et `System.Text.Json`, donc aucun paquet NuGet n'est à installer.

## Étape 1 : obtenir une clé d'API

Choisissez une offre sur les [tarifs](https://amodb.dev/fr/pricing/). Les offres ne diffèrent que par le débit, à 20, 40 ou 120 requêtes par minute. Créez une clé dans le tableau de bord de votre compte : une chaîne de 32 caractères hexadécimaux en minuscules. Exportez-la et testez-la avec un appel :

```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` signifie que la clé fonctionne. Un `401` signifie que la clé est fausse ou absente. Dans PowerShell, définissez la variable avec `$env:AMODB_KEY = "YOUR_API_KEY"`. La page [authentification](https://amodb.dev/fr/authentication/) présente le paramètre de requête `api_key` comme alternative à l'en-tête.

## Étape 2 : créer le projet

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

Le modèle .NET 8 active les usings implicites et les types référence non nullables. Cela importe déjà `System`, `System.IO`, `System.Linq` et `System.Net.Http`. Vous ajoutez les autres lignes `using` dans le fichier.

## Étape 3 : envoyer la première requête

Créez un seul `HttpClient` avec une adresse de base, un délai d'expiration de 10 secondes et l'en-tête Bearer. Terminez l'adresse de base par une barre oblique et écrivez les chemins relatifs sans barre oblique initiale. Une barre oblique initiale remplacerait la partie `/3` du chemin de 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);
```

## Étape 4 : analyser le JSON dans des records

La réponse utilise des noms en snake case comme `release_date`. Définissez `JsonNamingPolicy.SnakeCaseLower`, nouveau dans .NET 8, et écrivez des membres en PascalCase. Les champs que vous ne déclarez pas sont ignorés.

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

L'objet `ratings` apparaît parce que l'appel de détails ajoute `append_to_response=ratings`. Chacune de ses cinq sources est un objet avec `value` et `votes`, ou `null` quand la note manque. `votes` peut aussi valoir `null` : la vraie réponse pour Fight Club n'a pas de nombre de votes pour Kinopoisk, Metacritic ni Rotten Tomatoes. La page [films](https://amodb.dev/fr/movies/) liste tous les autres champs.

## Étape 5 : gérer les erreurs et les limites de débit

Enveloppez l'appel dans une boucle qui réagit au code de statut.

| Statut     | Signification                        | Ce que fait l'application                                                                     |
| ---------- | ------------------------------------ | --------------------------------------------------------------------------------------------- |
| 401        | Clé absente, fausse ou suspendue     | Afficher un message et s'arrêter, une nouvelle tentative ne peut pas aider.                   |
| 402        | La période de l'offre est terminée   | Afficher un message et s'arrêter. Renouveler sur les [tarifs](https://amodb.dev/fr/pricing/). |
| 404        | Aucune fiche pour cet identifiant    | Le signaler et quitter.                                                                       |
| 429        | Au-dessus de vos requêtes par minute | Attendre `Retry-After` secondes, plafonnées à 30, et réessayer au plus deux fois.             |
| 500 ou 504 | Échec côté serveur                   | Signaler le statut et relancer la commande plus tard.                                         |

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

Les défaillances réseau apparaissent sous forme de `HttpRequestException`, et un délai dépassé sous forme de `TaskCanceledException`. Interceptez les deux au niveau supérieur. Chaque réponse avec clé porte aussi `X-RateLimit-Remaining`, si bien qu'un long traitement peut ralentir avant d'atteindre un 429. Voir [limites de débit](https://amodb.dev/fr/rate-limits/) et [erreurs](https://amodb.dev/fr/errors/) pour les listes complètes.

## Étape 6 : mettre les réponses en cache sur disque

La vérification de la limite de débit passe avant le cache de périphérie : une réponse servie depuis la périphérie compte donc quand même dans vos requêtes par minute. Un cache dans votre propre programme est le seul moyen d'économiser des requêtes. La périphérie garde les détails 6 heures et les résultats de recherche 10 minutes, et l'application utilise les mêmes durées. Un fichier dans le dossier temporaire est frais quand sa date d'écriture est dans la durée de vie.

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

## Étape 7 : assembler le tout

Voici le `Program.cs` complet. Il ajoute la méthode `Set` au cache et la classe `AmodbClient` qui combine le cache, la boucle de nouvelle tentative et les options 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)!;
        }
    }
}
```

Lancez-le avec un titre. Voici la sortie pour le premier résultat de recherche, le film 550. Les nombres de votes augmentent avec le temps : les vôtres seront un peu différents.

```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 ligne TMDB affiche 8,4 parce que le code affiche une décimale du 8.437 de `ratings.tmdb.value`. L'hôte d'affiches `img.amodb.dev` n'exige aucune clé : vous pouvez donc passer l'URL de l'affiche directement à un navigateur ou à un contrôle d'image. Voir [images](https://amodb.dev/fr/images-api/) pour la liste des tailles.

## Pour aller plus loin

- Cherchez avec une année ou une pagination dans [recherche](https://amodb.dev/fr/search/), et filtrez les listes avec [découverte](https://amodb.dev/fr/discover/).
- Affichez toutes les notes et leurs règles pour null avec [notes](https://amodb.dev/fr/ratings/).
- Déplacez le code de la console dans un service ASP.NET Core avec `AddHttpClient` et `IMemoryCache`.
- Comparez la même tâche en [Java](https://amodb.dev/fr/java-movie-api/) ou en [Go](https://amodb.dev/fr/go-movie-api/).

## Questions et réponses

### Ai-je besoin d'un paquet NuGet pour appeler l'API depuis C# ?

Non. HttpClient et System.Text.Json sont fournis avec .NET 8, donc ce tutoriel n'ajoute aucun paquet. Envoyez la clé dans un en-tête Authorization Bearer, ou passez-la dans le paramètre de requête api_key si vous préférez.

### Comment lire l'en-tête Retry-After en .NET ?

Utilisez response.Headers.RetryAfter. Quand le serveur envoie un nombre de secondes, sa propriété Delta contient un TimeSpan. Attendez ce délai, plafonné à 30 secondes, et réessayez au plus deux fois avant de signaler le 429.

### Dois-je créer un HttpClient pour chaque requête ?

Non. Créez une seule instance et réutilisez-la, car chaque nouvelle instance ouvre de nouvelles connexions. Dans ASP.NET Core, enregistrez un client typé avec AddHttpClient et injectez-le. Une application console peut garder une seule instance pendant toute son exécution.

### Puis-je réutiliser des modèles C# écrits pour TMDB v3 ?

Oui. Pour les points de terminaison qu'amodb propose, les chemins, les paramètres et les champs JSON correspondent à TMDB v3 : les modèles que vous avez déjà écrits se désérialisent sans changement. Pointez l'adresse de base vers https://api.amodb.dev/3/ et utilisez votre clé amodb.

### Pourquoi la fiche affiche-t-elle « inconnu » pour une note ?

Une source de note vaut null quand amodb n'a aucune note pour ce titre. Traitez null comme inconnu, jamais comme zéro, et n'affichez que les sources présentes. De nouveaux champs peuvent apparaître avec le temps : ignorez ceux que vous ne connaissez pas.

## Pages liées

- [Tutoriel API de films en Java](https://amodb.dev/fr/java-movie-api/): Guide d'épisodes Java avec le HttpClient du JDK et les records Jackson : lire une saison, gérer Retry-After sur 429, cache en fichiers. Java 17 ou plus.
- [API de recherche](https://amodb.dev/fr/search/): API de recherche aux chemins et au JSON de TMDB v3 : films, séries, personnes, collections, sociétés et mots-clés par texte, filtres d'année et pagination.
- [API de détails de film](https://amodb.dev/fr/movies/): API de détails de film aux chemins et au JSON de TMDB v3. Obtenez un film par id, ajoutez crédits, notes et ambiances avec append_to_response, en 14 langues.
- [Notes des films et séries](https://amodb.dev/fr/ratings/): API de notes de films : scores IMDb, Kinopoisk, Metacritic, Rotten Tomatoes et TMDB d'un titre en un appel, avec null pour toute source inconnue.
- [Authentification](https://amodb.dev/fr/authentication/): Comment envoyer votre clé amodb : api_key dans la requête, en-tête Authorization Bearer, ou apikey pour OMDb. Format, stockage, rotation et erreurs 401.
- [Limites de débit](https://amodb.dev/fr/rate-limits/): amodb limite chaque clé en requêtes par minute sur une fenêtre glissante de 60 secondes : 20, 40 ou 120 selon l'offre. Lisez les en-têtes et gérez le 429.

Mis à jour: 2026-10-18. Source: https://amodb.dev/fr/csharp-movie-api/
