מדריך API לסרטים ב-C# עבור .NET 8
המדריך בונה אפליקציית קונסולה ב-.NET 8 שמקבלת כותרת סרט, מחפשת ב-amodb, טוענת את ההתאמה הטובה ביותר עם הדירוגים שלה ומדפיסה כרטיס קצר. היא משתמשת רק ב-HttpClient וב-System.Text.Json, קוראת את המפתח מ-AMODB_KEY, מנסה שוב ב-429 ושומרת תגובות במטמון בקבצים. ההקמה לוקחת כ-15 דקות.
מה בונים
בונים את MovieLookup, אפליקציית קונסולה שמקבלת כותרת משורת הפקודה, מחפשת ב-amodb, טוענת את ההתאמה הטובה ביותר עם הדירוגים שלה ומדפיסה כרטיס קצר עם קישור לפוסטר. צריך את ה-SDK של .NET 8, מפתח amodb וכ-15 דקות. התוכנית משתמשת רק ב-HttpClient וב-System.Text.Json, ולכן אין חבילות NuGet להתקנה.
שלב 1: קבלת מפתח API
בחרו מסלול בעמוד תמחור. המסלולים נבדלים רק בקצב, 20, 40 או 120 בקשות לדקה. צרו מפתח בלוח הבקרה של החשבון, מחרוזת של 32 תווים הקסדצימליים באותיות קטנות. ייצאו אותו ובדקו אותו עם קריאה אחת:
200 אומר שהמפתח עובד. 401 אומר שהמפתח שגוי או חסר. ב-PowerShell מגדירים את המשתנה עם $env:AMODB_. עמוד אימות מסביר את פרמטר השאילתה api_key כחלופה לכותרת.
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"שלב 2: יצירת הפרויקט
התבנית של .NET 8 מפעילה using מרומזים וטיפוסי הפניה שאינם מקבלים null. כך כבר מיובאים System, System.IO, System.Linq ו-System.Net.Http. את שורות ה-using האחרות מוסיפים בקובץ.
dotnet new console -n MovieLookup -f net8.0
cd MovieLookupשלב 3: שליחת הבקשה הראשונה
צרו HttpClient אחד עם כתובת בסיס, פסק זמן של 10 שניות וכותרת Bearer. סיימו את כתובת הבסיס בלוכסן וכתבו נתיבים יחסיים בלי לוכסן בהתחלה. לוכסן בהתחלה יחליף את החלק /3 בנתיב הבסיס.
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);שלב 4: פענוח ה-JSON לרשומות
התגובה משתמשת בשמות ב-snake case כמו release_date. הגדירו את JsonNamingPolicy.SnakeCaseLower, שחדש ב-.NET 8, וכתבו חברים ב-PascalCase. שדות שלא הצהרתם עליהם מתעלמים מהם.
האובייקט ratings מופיע כי קריאת הפרטים מוסיפה append_. כל אחד מחמשת המקורות שלו הוא אובייקט עם value ו-votes, או null כשהציון חסר. גם votes יכול להיות null: בתגובה החיה של Fight Club אין מספר הצבעות עבור Kinopoisk, Metacritic או Rotten Tomatoes. כל שאר השדות מפורטים בעמוד סרטים.
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);שלב 5: טיפול בשגיאות ובמגבלות קצב
עטפו את הקריאה בלולאה שמגיבה לקוד הסטטוס.
| סטטוס | משמעות | מה האפליקציה עושה |
|---|---|---|
| 401 | המפתח חסר, שגוי או מושעה | להדפיס הודעה ולעצור, ניסיון חוזר לא יעזור. |
| 402 | תקופת המסלול הסתיימה | להדפיס הודעה ולעצור. לחדש בעמוד תמחור. |
| 404 | אין רשומה למזהה הזה | לדווח ולצאת. |
| 429 | מעל הבקשות שלכם לדקה | להמתין Retry-After שניות, עד 30, ולנסות שוב פעמיים לכל היותר. |
| 500 או 504 | כשל בצד השרת | לדווח על הסטטוס ולהריץ את הפקודה שוב מאוחר יותר. |
כשלי רשת מופיעים כ-HttpRequestException, ופסק זמן מופיע כ-TaskCanceledException. תפסו את שניהם ברמה העליונה. כל תגובה עם מפתח נושאת גם X-RateLimit-Remaining, ולכן משימה ארוכה יכולה להאט לפני שהיא מגיעה ל-429. את הרשימות המלאות ראו בעמודים מגבלות קצב ושגיאות.
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
}שלב 6: שמירת תגובות במטמון בדיסק
בדיקת מגבלת הקצב רצה לפני מטמון הקצה, ולכן תשובה שהוגשה מהקצה עדיין נספרת בבקשות שלכם לדקה. מטמון בתוכנית שלכם הוא הדרך היחידה לחסוך בקשות. הקצה שומר פרטים 6 שעות ותוצאות חיפוש 10 דקות, ולכן האפליקציה משתמשת באותם משכים. קובץ בתיקיית הזמניים טרי כשזמן הכתיבה שלו בתוך ה-TTL.
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;
}
}שלב 7: מחברים הכול
זה הקובץ המלא Program.cs. הוא מוסיף את המתודה Set למטמון ואת המחלקה AmodbClient שמשלבת את המטמון, לולאת הניסיון החוזר ואפשרויות ה-JSON.
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)!;
}
}
}הריצו אותו עם כותרת. זה הפלט לתוצאת החיפוש הראשונה, סרט 550. מספרי ההצבעות גדלים עם הזמן, ולכן אצלכם הם יהיו קצת שונים.
שורת TMDB מציגה 8.4 כי הקוד מדפיס ספרה עשרונית אחת מתוך 8.437 ב-ratings.tmdb.value. מארח הפוסטרים img.amodb.dev לא דורש מפתח, ולכן אפשר להעביר את כתובת הפוסטר ישר לדפדפן או לפקד תמונה. רשימת הגדלים בעמוד תמונות.
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