מדריך API לסרטים ב-Java
בנו מדריך פרקים בשורת פקודה ב-Java שמדפיס כל פרק של עונת טלוויזיה עם מספר, תאריך שידור ומשך. הוא משתמש ב-HttpClient של ה-JDK וב-Jackson, ממפה JSON לרשומות, מנסה שוב אחרי 429 לפי ההשהיה ב-Retry-After ושומר תגובות בקבצים. נדרשים Java 17 ומעלה ו-Maven 3.9.
בונים את EpisodeGuide, תוכנית שורת פקודה. מריצים אותה עם מזהה סדרת טלוויזיה ומספר עונה והיא מדפיסה כל פרק עם מספר, תאריך שידור, משך ושם. היא משתמשת ב-HttpClient של ה-JDK, ב-Jackson 2.x, ב-Java 17 ומעלה וב-Maven 3.9. תכננו 20 דקות.
שלב 1: קבלת מפתח API
מפתח דורש מסלול בתשלום מראש, ולכן התחילו בעמוד תמחור. כל המסלולים מגישים את אותם נתונים, ורק הקצב לדקה שונה. צרו את המפתח בלוח הבקרה של החשבון וייצאו אותו:
התוכנית קוראת System.getenv("AMODB_ ומסרבת להתחיל בלעדיו.
export AMODB_KEY="YOUR_API_KEY"שלב 2: יצירת הפרויקט
Jackson היא התלות היחידה. צרו את התיקייה, קובץ pom.xml מינימלי ואת ספריית המקור:
גרסת 2.x חדשה יותר של jackson-databind עובדת גם היא.
mkdir episode-guide && cd episode-guide
mkdir -p src/main/java
cat > pom.xml <<'EOF'
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>episode-guide</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
</dependencies>
</project>
EOFשלב 3: שליחת הבקשה הראשונה
נקודת הקצה של עונה היא GET /, ו-Breaking Bad היא סדרה 1396. המחלקה הזו מדפיסה את קוד הסטטוס. שמרו אותה כ-src/ כדי לנסות אותה לבד. פקודת ההרצה בשלב 7 עובדת גם עבורה אם מגדירים -Dexec.mainClass=First.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class First {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.amodb.dev/3/tv/1396/season/1"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("AMODB_KEY"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
}
}שלב 4: מיפוי ה-JSON לרשומות
תגובת עונה כוללת name ומערך episodes. לכל פרק יש episode_number, name, air_date ו-runtime, ושני האחרונים יכולים להיות null בפרק שעדיין לא שודר. השתמשו ב-Integer למספר שיכול להיות חסר. רשימת השדות המלאה נמצאת בעמוד עונות ופרקים.
@JsonIgnoreProperties(ignoreUnknown = true)
public record Episode(
@JsonProperty("episode_number") int number,
String name,
@JsonProperty("air_date") String airDate,
Integer runtime) {}שלב 5: טיפול בשגיאות ובמגבלות קצב
קראו את הסטטוס לפני שנוגעים בגוף.
| סטטוס | מה התוכנית עושה |
|---|---|
| 200 | מחזירה את הגוף ל-Jackson. |
| 401 | זורקת IOException שמזכיר את AMODB_KEY. מפתח שגוי מכשיל כל קריאה, ולכן אין ניסיון חוזר. |
| 404 | זורקת עם status_message, למשל כשהעונה לא קיימת. |
| 429 | קוראת Retry-After, ישנה 1 עד 30 שניות ושולחת את הבקשה שוב, 3 ניסיונות לכל היותר. |
| פסק זמן או כשל חיבור | נזרקת מחדש כ-network error עם הסיבה מצורפת. |
גוף השגיאה הוא במבנה של TMDB, ולכן status_message הוא הטקסט להצגה. עמוד שגיאות מפרט כל קוד, ומגבלות קצב מסביר את החלון של 60 שניות.
שלב 6: שמירת תגובות בקבצים
הקצה שומר פרטים 6 שעות, ולכן רשימת פרקים משתנה לעיתים רחוקות בין הרצות. התוכנית כותבת כל גוף לקובץ בתיקיית הזמניים, בשם שנגזר מנתיב הבקשה, וקוראת אותו בחזרה כל עוד הקובץ צעיר מה-TTL. פגיעה במטמון לא שולחת דבר ולא צורכת מהבקשות שלכם לדקה.
שלב 7: התוכנית המלאה
שמרו את זה כ-src/.
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
public class EpisodeGuide {
private static final String BASE = "https://api.amodb.dev/3";
private static final String KEY = System.getenv("AMODB_KEY");
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final Path CACHE_DIR = Path.of(System.getProperty("java.io.tmpdir"), "amodb-cache");
@JsonIgnoreProperties(ignoreUnknown = true)
public record Episode(
@JsonProperty("episode_number") int number,
String name,
@JsonProperty("air_date") String airDate,
Integer runtime) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Season(String name, List<Episode> episodes) {}
static String get(String path) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + KEY)
.header("Accept", "application/json")
.GET()
.build();
for (int attempt = 1; ; attempt++) {
HttpResponse<String> response;
try {
response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
throw new IOException("network error: " + e, e);
}
int status = response.statusCode();
if (status == 200) {
return response.body();
}
if (status == 401) {
throw new IOException("401: the key is invalid or suspended, check AMODB_KEY");
}
if (status == 429 && attempt < 3) {
long seconds = response.headers().firstValueAsLong("Retry-After").orElse(1);
Thread.sleep(Math.min(Math.max(seconds, 1), 30) * 1000);
continue;
}
throw new IOException("amodb returned " + status + ": " + message(response.body()));
}
}
static String message(String body) {
try {
return MAPPER.readTree(body).path("status_message").asText("no message");
} catch (IOException e) {
return "no message";
}
}
static String cachedGet(String path, Duration ttl) throws IOException, InterruptedException {
Files.createDirectories(CACHE_DIR);
Path file = CACHE_DIR.resolve(path.replaceAll("[^A-Za-z0-9]", "_") + ".json");
if (Files.exists(file)) {
Instant modified = Files.getLastModifiedTime(file).toInstant();
if (modified.plus(ttl).isAfter(Instant.now())) {
return Files.readString(file);
}
}
String body = get(path);
Files.writeString(file, body);
return body;
}
public static void main(String[] args) {
if (args.length != 2) {
System.err.println("Usage: EpisodeGuide <tv_id> <season_number>");
System.exit(2);
}
if (KEY == null || KEY.isBlank()) {
System.err.println("Set AMODB_KEY first.");
System.exit(2);
}
String path;
try {
path = "/tv/" + Integer.parseInt(args[0]) + "/season/" + Integer.parseInt(args[1]) + "?language=en-US";
} catch (NumberFormatException e) {
System.err.println("The series id and season number must be integers.");
System.exit(2);
return;
}
try {
Season season = MAPPER.readValue(cachedGet(path, Duration.ofHours(6)), Season.class);
System.out.println(season.name() + ", " + season.episodes().size() + " episodes");
for (Episode ep : season.episodes()) {
System.out.printf("%2d %-10s %-7s %s%n",
ep.number(),
Objects.requireNonNullElse(ep.airDate(), "TBA"),
ep.runtime() == null ? "?" : ep.runtime() + " min",
ep.name());
}
} catch (IOException e) {
System.err.println("error: " + e.getMessage());
System.exit(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.exit(1);
}
}
}קמפלו והריצו עבור עונה 1 של סדרה 1396:
הפלט כולל שורת כותרת אחת עם שם העונה ומספר הפרקים, ואז שורה לכל פרק. פרק בלי תאריך שידור מציג TBA, ופרק בלי משך מציג ?. הריצו את הפקודה פעמיים: הריצה השנייה קוראת את הקובץ מתיקיית הזמניים ולא שולחת בקשה, עד שיעברו 6 שעות. מחקו את התיקייה amodb-cache כדי לכפות נתונים טריים.
שמרו את ה-HttpClient ואת ה-ObjectMapper בשדות סטטיים. שניהם בטוחים לשימוש בכמה תהליכונים ויקרים ליצירה, ולכן מופע אחד לכל תהליך הוא הדפוס הרגיל, גם בשירות רשת.
mvn -q compile exec:java -Dexec.mainClass=EpisodeGuide -Dexec.args="1396 1"הצעדים הבאים
- עברו בלולאה מעונה 1 עד
number_of_seasonsמתוך פרטי סדרת טלוויזיה והדפיסו את הסדרה כולה. - הציגו את הפרק הבא של סדרה שעדיין משודרת עם
next_episode_to_air. - הוסיפו ארגומנט שלישי לפרמטר
language, וראו אילו תגיות מוגשות בעמוד שפות. - השוו לאותו דפוס ב-Go, או התחילו מסקירת API למדריך פרקים.