1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
|
// Data model for AnimeSchedule.net's weekly timetable, the source of truth for
// when subbed and dubbed episodes actually release. Unlike a fansub schedule,
// every release carries an absolute timestamp, an episode number, delay windows,
// and the streaming platforms it lands on.
export type AirType = "sub" | "dub";
export interface Stream {
platform: string;
url: string;
name: string;
}
// A single scheduled episode release for one show.
export interface AiringEntry {
route: string;
title: string;
romaji: string;
english: string;
native: string;
episodeNumber: number;
airingAt: number;
delayedUntil?: number;
imageUrl: string;
streams: Stream[];
}
// The merged sub + dub schedule for the current week.
export interface AiringSchedule {
generatedAt: number;
sub: AiringEntry[];
dub: AiringEntry[];
}
const IMAGE_BASE =
"https://img.animeschedule.net/production/assets/public/img/";
const animeScheduleImageUrl = (imageVersionRoute: string): string =>
imageVersionRoute ? `${IMAGE_BASE}${imageVersionRoute}` : "";
// AnimeSchedule uses 0001-01-01 as its "no date" sentinel.
const isZeroDate = (value: string | undefined): boolean =>
!value || value.startsWith("0001-01-01");
const toEpochSeconds = (value: string | undefined): number =>
isZeroDate(value)
? 0
: Math.floor(new Date(value as string).getTime() / 1000);
// Stream URLs come back without a scheme (e.g. "www.hidive.com/...").
const withScheme = (url: string): string =>
!url ? "" : /^https?:\/\//.test(url) ? url : `https://${url}`;
interface RawTimetableEntry {
title: string;
route: string;
romaji: string;
english: string;
native: string;
episodeNumber: number;
episodeDate: string;
delayedUntil: string;
imageVersionRoute: string;
streams: { platform: string; url: string; name: string }[];
}
export const parseTimetable = (raw: unknown): AiringEntry[] => {
if (!Array.isArray(raw)) return [];
return (raw as RawTimetableEntry[]).map((entry) => {
const delayedUntil = toEpochSeconds(entry.delayedUntil);
return {
route: entry.route,
title: entry.english || entry.romaji || entry.title,
romaji: entry.romaji || "",
english: entry.english || "",
native: entry.native || "",
episodeNumber: entry.episodeNumber,
airingAt: toEpochSeconds(entry.episodeDate),
delayedUntil: delayedUntil || undefined,
imageUrl: animeScheduleImageUrl(entry.imageVersionRoute),
streams: (entry.streams || []).map((stream) => ({
platform: stream.platform,
url: withScheme(stream.url),
name: stream.name,
})),
};
});
};
const TIMETABLE_ENDPOINT = "https://animeschedule.net/api/v3/timetables";
// Fetch and parse the current week's sub and dub timetables in one shot. The
// caller supplies the AnimeSchedule application token.
export const fetchTimetables = async (
token: string,
): Promise<{ sub: AiringEntry[]; dub: AiringEntry[] }> => {
const fetchOne = async (airType: AirType): Promise<AiringEntry[]> => {
try {
const response = await fetch(`${TIMETABLE_ENDPOINT}?airType=${airType}`, {
headers: { Authorization: `Bearer ${token}` },
});
return response.ok ? parseTimetable(await response.json()) : [];
} catch {
return [];
}
};
const [sub, dub] = await Promise.all([fetchOne("sub"), fetchOne("dub")]);
return { sub, dub };
};
|