Skip to content

Commit f83fc36

Browse files
committed
2 parents c550886 + d215abe commit f83fc36

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2510
-268
lines changed

apps/api/src/index.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import "dotenv/config";
2-
import { pgGetTrackPointsByid } from "@sk/db/pg";
3-
import { rdsGetRingStorage, rdsGetSingle } from "@sk/db/redis";
2+
import { pgGetAirportPilots, pgGetTrackPointsByid } from "@sk/db/pg";
3+
import { rdsGetMultiple, rdsGetRingStorage, rdsGetSingle } from "@sk/db/redis";
44
import cors from "cors";
55
import express from "express";
66

@@ -59,7 +59,7 @@ app.get("/data/init", async (_req, res) => {
5959
app.get("/data/pilot/:id", async (req, res) => {
6060
try {
6161
const { id } = req.params;
62-
console.log("Requested pilot:", id);
62+
// console.log("Requested pilot:", id);
6363

6464
const pilot = await rdsGetSingle(`pilot:${id}`);
6565
if (!pilot) return res.status(404).json({ error: "Pilot not found" });
@@ -74,7 +74,7 @@ app.get("/data/pilot/:id", async (req, res) => {
7474
app.get("/data/airport/:icao", async (req, res) => {
7575
try {
7676
const { icao } = req.params;
77-
console.log("Requested airport:", icao);
77+
// console.log("Requested airport:", icao);
7878

7979
const airport = await rdsGetSingle(`airport:${icao}`);
8080
if (!airport) return res.status(404).json({ error: "Airport not found" });
@@ -86,15 +86,16 @@ app.get("/data/airport/:icao", async (req, res) => {
8686
}
8787
});
8888

89-
app.get("/data/controller/:callsign", async (req, res) => {
89+
app.get("/data/controllers/:callsigns", async (req, res) => {
9090
try {
91-
const { callsign } = req.params;
92-
console.log("Requested controller:", callsign);
91+
const { callsigns } = req.params;
92+
// console.log("Requested controller:", callsigns);
9393

94-
const controller = await rdsGetSingle(`controller:${callsign}`);
95-
if (!controller) return res.status(404).json({ error: "Controller not found" });
94+
const controllers = await rdsGetMultiple("controller", callsigns.split(","));
95+
const validControllers = controllers.filter((controller) => controller !== null);
96+
if (!validControllers) return res.status(404).json({ error: "Controller not found" });
9697

97-
res.json(controller);
98+
res.json(validControllers);
9899
} catch (err) {
99100
console.error(err);
100101
res.status(500).json({ error: "Internal server error" });
@@ -104,7 +105,7 @@ app.get("/data/controller/:callsign", async (req, res) => {
104105
app.get("/data/track/:id", async (req, res) => {
105106
try {
106107
const { id } = req.params;
107-
console.log("Requested track:", id);
108+
// console.log("Requested track:", id);
108109

109110
const trackPoints = await pgGetTrackPointsByid(id);
110111

@@ -118,7 +119,7 @@ app.get("/data/track/:id", async (req, res) => {
118119
app.get("/data/aircraft/:reg", async (req, res) => {
119120
try {
120121
const { reg } = req.params;
121-
console.log("Requested aircraft:", reg);
122+
// console.log("Requested aircraft:", reg);
122123

123124
const aircraft = await rdsGetSingle(`fleet:${reg}`);
124125
if (!aircraft) return res.status(404).json({ error: "Aircraft not found" });
@@ -144,6 +145,23 @@ app.get("/data/dashboard/", async (_req, res) => {
144145
}
145146
});
146147

148+
// GET /data/airport/<icao>/flights?direction=<direction>&limit=<limit>&cursor=<base64string>
149+
app.get("/data/airport/:icao/flights", async (req, res) => {
150+
try {
151+
const icao = String(req.params.icao).toUpperCase();
152+
const direction = (String(req.query.direction || "dep").toLowerCase() === "arr" ? "arr" : "dep") as "dep" | "arr";
153+
const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 20));
154+
const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined;
155+
const afterCursor = typeof req.query.afterCursor === "string" ? req.query.afterCursor : undefined;
156+
157+
const data = await pgGetAirportPilots(icao, direction, limit, cursor, afterCursor);
158+
res.json(data);
159+
} catch (err) {
160+
console.error(err);
161+
res.status(500).json({ error: "Internal server error" });
162+
}
163+
});
164+
147165
const PORT = process.env.API_PORT || 3001;
148166
app.listen(PORT, () => {
149167
console.log(`Express API listening on port ${PORT}`);

apps/ingestion/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
},
1111
"devDependencies": {
1212
"@sk/typescript-config": "*",
13+
"@types/xml2js": "^0.4.14",
1314
"tsup": "^8.5.0",
1415
"typescript": "^5.9.3"
1516
},
1617
"dependencies": {
1718
"@sk/db": "*",
1819
"@sk/types": "*",
1920
"axios": "^1.13.2",
20-
"dotenv": "^17.2.3"
21+
"dotenv": "^17.2.3",
22+
"xml2js": "^0.6.2"
2123
}
2224
}

apps/ingestion/src/airport.ts

Lines changed: 133 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,43 @@
1+
import { promisify } from "node:util";
2+
import * as zlib from "node:zlib";
13
import type { AirportDelta, AirportLong, AirportShort, PilotLong } from "@sk/types/vatsim";
4+
import axios from "axios";
5+
import { parseStringPromise } from "xml2js";
6+
7+
interface MetarXML {
8+
response?: {
9+
data?: Array<{
10+
METAR?: Array<{
11+
station_id?: string[];
12+
raw_text?: string[];
13+
}>;
14+
}>;
15+
};
16+
}
17+
18+
interface TafXML {
19+
response?: {
20+
data?: Array<{
21+
TAF?: Array<{
22+
station_id?: string[];
23+
raw_text?: string[];
24+
}>;
25+
}>;
26+
};
27+
}
28+
29+
const METAR_URL = "https://aviationweather.gov/data/cache/metars.cache.xml.gz";
30+
const TAF_URL = "https://aviationweather.gov/data/cache/tafs.cache.xml.gz";
31+
const WEATHER_FETCH_INTERVAL = 600_000;
232

333
let cached: AirportLong[] = [];
434
let deleted: string[] = [];
535
let updated: AirportShort[] = [];
636
let added: AirportShort[] = [];
737

8-
export function mapAirports(pilotsLong: PilotLong[]): AirportLong[] {
38+
export async function mapAirports(pilotsLong: PilotLong[]): Promise<AirportLong[]> {
39+
await updateWeather();
40+
941
const airportRecord: Record<string, AirportLong> = {};
1042
const routeRecord: Record<string, Map<string, number>> = {};
1143

@@ -59,18 +91,38 @@ export function mapAirports(pilotsLong: PilotLong[]): AirportLong[] {
5991
const routes = routeRecord[icao];
6092
if (!routes) continue;
6193

62-
let busiestRoute = "-";
63-
let maxFlights = 0;
94+
let busiestDeparture = "-";
95+
let busiestArrival = "-";
96+
let busiestDepCount = 0;
97+
let busiestArrCount = 0;
98+
let uniqueDepartures = 0;
99+
let uniqueArrivals = 0;
64100

65101
routes.forEach((count, route) => {
66-
if (count > maxFlights) {
67-
maxFlights = count;
68-
busiestRoute = route;
102+
const [depIcao, arrIcao] = route.split("-");
103+
if (depIcao === icao) {
104+
uniqueDepartures++;
105+
if (count > busiestDepCount) {
106+
busiestDeparture = route;
107+
busiestDepCount = count;
108+
}
109+
} else if (arrIcao === icao) {
110+
uniqueArrivals++;
111+
if (count > busiestArrCount) {
112+
busiestArrival = route;
113+
busiestArrCount = count;
114+
}
69115
}
70116
});
71117

72-
airportRecord[icao].busiest_route = busiestRoute;
73-
airportRecord[icao].total_routes = routes.size;
118+
airportRecord[icao].busiest = {
119+
departure: busiestDeparture,
120+
arrival: busiestArrival,
121+
};
122+
airportRecord[icao].unique = {
123+
departures: uniqueDepartures,
124+
arrivals: uniqueArrivals,
125+
};
74126
}
75127

76128
const airportsLong = Object.values(airportRecord);
@@ -109,8 +161,10 @@ function initAirportRecord(icao: string): AirportLong {
109161
icao: icao,
110162
dep_traffic: { traffic_count: 0, average_delay: 0, flights_delayed: 0 },
111163
arr_traffic: { traffic_count: 0, average_delay: 0, flights_delayed: 0 },
112-
busiest_route: "",
113-
total_routes: 0,
164+
busiest: { departure: "-", arrival: "-" },
165+
unique: { departures: 0, arrivals: 0 },
166+
metar: getMetar(icao),
167+
taf: getTaf(icao),
114168
};
115169
}
116170

@@ -129,3 +183,72 @@ function calculateArrivalDelay(pilot: PilotLong): number {
129183

130184
return Math.min(Math.max(delay_min, 0), 120);
131185
}
186+
187+
const gunzip = promisify(zlib.gunzip);
188+
let metarCache: Map<string, string> = new Map();
189+
let tafCache: Map<string, string> = new Map();
190+
let lastWeatherFetch = 0;
191+
192+
async function fetchWeather(url: string): Promise<MetarXML | TafXML> {
193+
const response = await axios.get<Buffer>(url, {
194+
responseType: "arraybuffer",
195+
});
196+
197+
const decompressed = await gunzip(response.data);
198+
const xml = decompressed.toString("utf-8");
199+
200+
const parsed = (await parseStringPromise(xml)) as MetarXML | TafXML;
201+
202+
return parsed;
203+
}
204+
205+
async function updateWeather(): Promise<void> {
206+
if (Date.now() - lastWeatherFetch < WEATHER_FETCH_INTERVAL) {
207+
return;
208+
}
209+
lastWeatherFetch = Date.now();
210+
211+
try {
212+
const parsedMetar = (await fetchWeather(METAR_URL)) as MetarXML;
213+
const parsedTaf = (await fetchWeather(TAF_URL)) as TafXML;
214+
215+
const metars = parsedMetar?.response?.data?.[0]?.METAR || [];
216+
const tafs = parsedTaf?.response?.data?.[0]?.TAF || [];
217+
218+
const newMetarCache = new Map<string, string>();
219+
const newTafCache = new Map<string, string>();
220+
221+
for (const metar of metars) {
222+
const icao = metar.station_id?.[0];
223+
const raw = metar.raw_text?.[0];
224+
225+
if (icao && raw) {
226+
newMetarCache.set(icao, raw);
227+
}
228+
}
229+
230+
for (const taf of tafs) {
231+
const icao = taf.station_id?.[0];
232+
const raw = taf.raw_text?.[0];
233+
234+
if (icao && raw) {
235+
newTafCache.set(icao, raw);
236+
}
237+
}
238+
239+
metarCache = newMetarCache;
240+
tafCache = newTafCache;
241+
242+
// console.log(`✅ Updated ${metarCache.size} METAR entries and ${tafCache.size} TAF entries`);
243+
} catch (error) {
244+
console.error("❌ Error fetching weather data:", error instanceof Error ? error.message : error);
245+
}
246+
}
247+
248+
export function getMetar(icao: string): string | null {
249+
return metarCache.get(icao) || null;
250+
}
251+
252+
export function getTaf(icao: string): string | null {
253+
return tafCache.get(icao) || null;
254+
}

0 commit comments

Comments
 (0)