mapsdl
This commit is contained in:
parent
cf65959148
commit
f66c505082
@ -2,7 +2,7 @@ FROM denoland/deno:debian-2.1.4
|
|||||||
|
|
||||||
# Install archive extraction tools
|
# Install archive extraction tools
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
unrar \
|
unar \
|
||||||
unzip \
|
unzip \
|
||||||
bzip2 \
|
bzip2 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@ -13,19 +13,23 @@ WORKDIR /app
|
|||||||
# Copy the script
|
# Copy the script
|
||||||
COPY mapsdl.ts .
|
COPY mapsdl.ts .
|
||||||
|
|
||||||
|
# Run as UID 1000 to match cssds (steam user)
|
||||||
|
RUN groupadd -g 1000 steam && useradd -u 1000 -g steam -m steam
|
||||||
|
|
||||||
# Create directories for credentials and data
|
# Create directories for credentials and data
|
||||||
RUN mkdir -p /app/credentials /maps /tmp/mapsdl
|
RUN mkdir -p /app/credentials /maps /tmp/mapsdl && \
|
||||||
|
chown steam:steam /maps /tmp/mapsdl
|
||||||
|
|
||||||
# Cache Deno dependencies
|
# Cache Deno dependencies
|
||||||
RUN deno cache --allow-scripts=npm:* mapsdl.ts
|
RUN deno cache --allow-import mapsdl.ts
|
||||||
|
|
||||||
# Set default environment variables
|
# Set default environment variables
|
||||||
ENV MAPS_DIR=/maps
|
ENV MAPS_DIR=/maps
|
||||||
ENV TEMP_DIR=/tmp/mapsdl
|
ENV TEMP_DIR=/tmp/mapsdl
|
||||||
ENV GOOGLE_APPLICATION_CREDENTIALS=/app/credentials/service-account.json
|
ENV GOOGLE_APPLICATION_CREDENTIALS=/app/credentials/service-account.json
|
||||||
|
|
||||||
# Run as non-root user
|
# Run as same UID as cssds
|
||||||
USER deno
|
USER steam
|
||||||
|
|
||||||
# Default command runs dry-run mode
|
# Default command runs dry-run mode
|
||||||
CMD ["run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-sys", "mapsdl.ts", "--dry-run"]
|
CMD ["run", "--allow-import", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-sys", "mapsdl.ts", "--dry-run"]
|
||||||
|
|||||||
586
src/mapsdl/mapsdl-all.ts
Executable file
586
src/mapsdl/mapsdl-all.ts
Executable file
@ -0,0 +1,586 @@
|
|||||||
|
// #!/usr/bin/env -S deno run --allow-net --allow-read --allow-write --allow-env --allow-sys
|
||||||
|
|
||||||
|
// this script should be run with deno
|
||||||
|
// use async stdlib fns where possible
|
||||||
|
|
||||||
|
import { parse } from "https://deno.land/std@0.210.0/flags/mod.ts";
|
||||||
|
import { join } from "https://deno.land/std@0.210.0/path/mod.ts";
|
||||||
|
import { type CredentialsClient, Drive } from "https://googleapis.deno.dev/v1/drive:v3.ts";
|
||||||
|
import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.14.4/index.ts";
|
||||||
|
|
||||||
|
// Service account auth that exchanges JWT for an OAuth2 access token
|
||||||
|
class ServiceAccountAuth implements CredentialsClient {
|
||||||
|
#clientEmail: string;
|
||||||
|
#privateKey: string;
|
||||||
|
#tokenUri: string;
|
||||||
|
#scope: string;
|
||||||
|
#accessToken: string | null = null;
|
||||||
|
#tokenExpiry = 0;
|
||||||
|
|
||||||
|
constructor(json: { client_email: string; private_key: string; token_uri?: string }, scope: string) {
|
||||||
|
this.#clientEmail = json.client_email;
|
||||||
|
this.#privateKey = json.private_key;
|
||||||
|
this.#tokenUri = json.token_uri ?? "https://oauth2.googleapis.com/token";
|
||||||
|
this.#scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRequestHeaders(_url?: string): Promise<Record<string, string>> {
|
||||||
|
if (!this.#accessToken || Date.now() / 1000 > this.#tokenExpiry - 60) {
|
||||||
|
await this.#refreshToken();
|
||||||
|
}
|
||||||
|
return { Authorization: `Bearer ${this.#accessToken}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async #refreshToken(): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const key = await importPKCS8(this.#privateKey, "RS256");
|
||||||
|
const assertion = await new SignJWT({
|
||||||
|
iss: this.#clientEmail,
|
||||||
|
scope: this.#scope,
|
||||||
|
aud: this.#tokenUri,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpirationTime(now + 3600)
|
||||||
|
.sign(key);
|
||||||
|
|
||||||
|
const resp = await fetch(this.#tokenUri, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||||
|
assertion,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`Token exchange failed (${resp.status}): ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
this.#accessToken = data.access_token;
|
||||||
|
this.#tokenExpiry = now + (data.expires_in ?? 3600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
sqlUrl: "https://raw.githubusercontent.com/bhopppp/Shavit-Surf-Timer/master/sql/surfzones.sql",
|
||||||
|
mapsDir: Deno.env.get("MAPS_DIR") || "/home/ntr/surf_megastructure/data/cssds/cstrike/maps",
|
||||||
|
tempDir: Deno.env.get("TEMP_DIR") || "/tmp/mapsdl",
|
||||||
|
serviceAccountPath: Deno.env.get("GOOGLE_APPLICATION_CREDENTIALS") || "./credentials/service-account.json",
|
||||||
|
gdriveFolders: {
|
||||||
|
aToK: "17QJ-Wzk9eMHKZqX227HkPCg9_Vmrf9h-",
|
||||||
|
lToZ: "1f3Oe65BngrSxTPKHAt6MEwK0FTsDbUsO",
|
||||||
|
},
|
||||||
|
concurrency: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GDriveFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Manifest {
|
||||||
|
total: number;
|
||||||
|
found: number;
|
||||||
|
missing: string[];
|
||||||
|
notInGDrive: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Download and parse SQL file for map names
|
||||||
|
async function downloadSurfZonesSQL(): Promise<string> {
|
||||||
|
console.log(`[DL] Downloading SQL file from ${CONFIG.sqlUrl}...`);
|
||||||
|
const response = await fetch(CONFIG.sqlUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download SQL: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMapNamesFromSQL(sqlContent: string): Set<string> {
|
||||||
|
console.log("[>>] Extracting map names from SQL...");
|
||||||
|
|
||||||
|
const mapNames = new Set<string>();
|
||||||
|
|
||||||
|
// Match INSERT statements and extract map names (surf_*)
|
||||||
|
// Handles both single and multi-value INSERT statements
|
||||||
|
const insertRegex = /INSERT INTO\s+`?(?:map)?zones`?\s+.*?VALUES\s*\((.*?)\);/gis;
|
||||||
|
|
||||||
|
for (const match of sqlContent.matchAll(insertRegex)) {
|
||||||
|
const valuesSection = match[1];
|
||||||
|
|
||||||
|
// Extract all string literals from the VALUES section
|
||||||
|
const stringRegex = /'([^']+)'/g;
|
||||||
|
|
||||||
|
for (const stringMatch of valuesSection.matchAll(stringRegex)) {
|
||||||
|
const value = stringMatch[1];
|
||||||
|
|
||||||
|
// Only keep values that start with 'surf_'
|
||||||
|
if (value.startsWith("surf_")) {
|
||||||
|
mapNames.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OK] Found ${mapNames.size} unique surf maps in SQL`);
|
||||||
|
return mapNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check which maps exist locally
|
||||||
|
async function getLocalMaps(): Promise<Set<string>> {
|
||||||
|
console.log(`[DIR] Scanning local maps directory: ${CONFIG.mapsDir}`);
|
||||||
|
|
||||||
|
const localMaps = new Set<string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const entry of Deno.readDir(CONFIG.mapsDir)) {
|
||||||
|
if (entry.isFile && entry.name.endsWith(".bsp")) {
|
||||||
|
// Remove .bsp extension and add to set
|
||||||
|
const mapName = entry.name.replace(/\.bsp$/, "");
|
||||||
|
localMaps.add(mapName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Deno.errors.NotFound) {
|
||||||
|
console.warn(`[!] Maps directory not found: ${CONFIG.mapsDir}`);
|
||||||
|
await Deno.mkdir(CONFIG.mapsDir, { recursive: true });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OK] Found ${localMaps.size} local maps`);
|
||||||
|
return localMaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Build manifest of missing maps
|
||||||
|
function buildManifest(
|
||||||
|
requiredMaps: Set<string>,
|
||||||
|
localMaps: Set<string>,
|
||||||
|
): Manifest {
|
||||||
|
console.log("[STATS] Building manifest of missing maps...");
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
for (const mapName of requiredMaps) {
|
||||||
|
if (!localMaps.has(mapName)) {
|
||||||
|
missing.push(mapName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: requiredMaps.size,
|
||||||
|
found: localMaps.size,
|
||||||
|
missing,
|
||||||
|
notInGDrive: [], // Will be populated after checking GDrive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Drive API client with service account
|
||||||
|
class GoogleDriveClient {
|
||||||
|
private drive!: Drive;
|
||||||
|
private auth!: CredentialsClient;
|
||||||
|
|
||||||
|
async authenticate(): Promise<void> {
|
||||||
|
console.log("[AUTH] Authenticating with Google Drive API...");
|
||||||
|
|
||||||
|
const keyFile = await Deno.readTextFile(CONFIG.serviceAccountPath);
|
||||||
|
this.auth = new ServiceAccountAuth(
|
||||||
|
JSON.parse(keyFile),
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
|
);
|
||||||
|
this.drive = new Drive(this.auth);
|
||||||
|
|
||||||
|
console.log("[OK] Authentication successful");
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(folderId: string): Promise<GDriveFile[]> {
|
||||||
|
const files: GDriveFile[] = [];
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = await this.drive.filesList({
|
||||||
|
q: `'${folderId}' in parents and trashed=false`,
|
||||||
|
pageSize: 1000,
|
||||||
|
pageToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const f of response.files ?? []) {
|
||||||
|
if (f.id && f.name) {
|
||||||
|
files.push({ id: f.id, name: f.name, size: String(f.size ?? "") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageToken = response.nextPageToken ?? undefined;
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(fileId: string, destPath: string): Promise<void> {
|
||||||
|
const url = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`;
|
||||||
|
const headers = await this.auth.getRequestHeaders(url);
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
await Deno.writeFile(destPath, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Search Google Drive for missing maps
|
||||||
|
async function searchGDriveForMaps(
|
||||||
|
client: GoogleDriveClient,
|
||||||
|
manifest: Manifest,
|
||||||
|
): Promise<Map<string, GDriveFile>> {
|
||||||
|
console.log("\n[CLOUD] Searching Google Drive folders...");
|
||||||
|
|
||||||
|
// List files from both folders
|
||||||
|
const [filesAK, filesLZ] = await Promise.all([
|
||||||
|
client.listFiles(CONFIG.gdriveFolders.aToK),
|
||||||
|
client.listFiles(CONFIG.gdriveFolders.lToZ),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allFiles = [...filesAK, ...filesLZ];
|
||||||
|
console.log(`[OK] Found ${allFiles.length} files in Google Drive`);
|
||||||
|
console.log(` - A-K folder: ${filesAK.length} files`);
|
||||||
|
console.log(` - L-Z folder: ${filesLZ.length} files`);
|
||||||
|
|
||||||
|
// Build map of normalized names to GDrive files
|
||||||
|
const gdriveMap = new Map<string, GDriveFile>();
|
||||||
|
|
||||||
|
for (const file of allFiles) {
|
||||||
|
// Normalize: remove archive extensions (.rar, .bz2, .zip, .bsp)
|
||||||
|
const normalized = file.name
|
||||||
|
.replace(/\.bsp\.bz2$/i, "")
|
||||||
|
.replace(/\.(rar|bz2|zip|bsp)$/i, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Only add if it looks like a map name (starts with surf_)
|
||||||
|
if (normalized.startsWith("surf_")) {
|
||||||
|
gdriveMap.set(normalized, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: Show first 5 files from each source
|
||||||
|
if (Deno.args.includes("--verbose")) {
|
||||||
|
console.log("\n[>>] Debug - First 5 GDrive files (normalized):");
|
||||||
|
Array.from(gdriveMap.keys()).slice(0, 5).forEach(name => {
|
||||||
|
console.log(` - "${name}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n[>>] Debug - First 5 SQL maps:");
|
||||||
|
manifest.missing.slice(0, 5).forEach(name => {
|
||||||
|
console.log(` - "${name.toLowerCase().trim()}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which missing maps are available in GDrive
|
||||||
|
const availableMaps = new Map<string, GDriveFile>();
|
||||||
|
|
||||||
|
for (const mapName of manifest.missing) {
|
||||||
|
const normalized = mapName.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (gdriveMap.has(normalized)) {
|
||||||
|
availableMaps.set(mapName, gdriveMap.get(normalized)!);
|
||||||
|
} else {
|
||||||
|
manifest.notInGDrive.push(mapName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n[STATS] Cross-reference results:`);
|
||||||
|
console.log(` [OK] Available to download: ${availableMaps.size} maps`);
|
||||||
|
console.log(` [X] Not in Google Drive: ${manifest.notInGDrive.length} maps`);
|
||||||
|
|
||||||
|
if (manifest.notInGDrive.length > 0) {
|
||||||
|
console.log(`\n[!] Maps not found in Google Drive (showing first 10):`);
|
||||||
|
manifest.notInGDrive.slice(0, 10).forEach(map => {
|
||||||
|
console.log(` - ${map}`);
|
||||||
|
});
|
||||||
|
if (manifest.notInGDrive.length > 10) {
|
||||||
|
console.log(` ... and ${manifest.notInGDrive.length - 10} more`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableMaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-device safe move (copy + delete)
|
||||||
|
async function moveFile(src: string, dest: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Deno.rename(src, dest);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).message.includes("os error 18")) {
|
||||||
|
await Deno.copyFile(src, dest);
|
||||||
|
await Deno.remove(src);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Download and extract maps
|
||||||
|
async function downloadAndExtractMaps(
|
||||||
|
client: GoogleDriveClient,
|
||||||
|
mapsToDownload: Map<string, GDriveFile>,
|
||||||
|
): Promise<{ success: number; failed: number }> {
|
||||||
|
console.log(`\n[DL] Downloading ${mapsToDownload.size} maps...`);
|
||||||
|
|
||||||
|
// Create temp directory
|
||||||
|
await Deno.mkdir(CONFIG.tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const stats = { success: 0, failed: 0 };
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
// Process maps with concurrency control
|
||||||
|
const entries = Array.from(mapsToDownload.entries());
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i += CONFIG.concurrency) {
|
||||||
|
const batch = entries.slice(i, i + CONFIG.concurrency);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async ([mapName, gdriveFile]) => {
|
||||||
|
try {
|
||||||
|
completed++;
|
||||||
|
console.log(`[${completed}/${mapsToDownload.size}] Downloading ${mapName}...`);
|
||||||
|
|
||||||
|
const archivePath = join(CONFIG.tempDir, gdriveFile.name);
|
||||||
|
const bspPath = join(CONFIG.mapsDir, `${mapName}.bsp`);
|
||||||
|
|
||||||
|
// Download archive
|
||||||
|
await client.downloadFile(gdriveFile.id, archivePath);
|
||||||
|
|
||||||
|
// Extract based on file type
|
||||||
|
if (gdriveFile.name.endsWith(".rar")) {
|
||||||
|
await extractRAR(archivePath, mapName, CONFIG.mapsDir);
|
||||||
|
await Deno.remove(archivePath); // Cleanup archive
|
||||||
|
} else if (gdriveFile.name.endsWith(".bz2")) {
|
||||||
|
await decompressBZ2(archivePath, bspPath);
|
||||||
|
await Deno.remove(archivePath); // Cleanup archive
|
||||||
|
} else if (gdriveFile.name.endsWith(".zip")) {
|
||||||
|
await extractZIP(archivePath, mapName, CONFIG.mapsDir);
|
||||||
|
await Deno.remove(archivePath); // Cleanup archive
|
||||||
|
} else if (gdriveFile.name.endsWith(".bsp")) {
|
||||||
|
// If already a BSP, just move it
|
||||||
|
await moveFile(archivePath, bspPath);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown archive format: ${gdriveFile.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a valid BSP file
|
||||||
|
await verifyBSP(bspPath);
|
||||||
|
|
||||||
|
stats.success++;
|
||||||
|
console.log(` [OK] ${mapName} downloaded and extracted`);
|
||||||
|
} catch (error) {
|
||||||
|
stats.failed++;
|
||||||
|
console.error(` [X] Failed to download ${mapName}: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractRAR(archivePath: string, mapName: string, destDir: string): Promise<void> {
|
||||||
|
// Use system unrar command to extract
|
||||||
|
const tempExtractDir = join(CONFIG.tempDir, `extract_${mapName}`);
|
||||||
|
await Deno.mkdir(tempExtractDir, { recursive: true });
|
||||||
|
|
||||||
|
const process = new Deno.Command("unar", {
|
||||||
|
args: ["-f", "-o", tempExtractDir, archivePath],
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, stderr } = await process.output();
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errorMsg = new TextDecoder().decode(stderr);
|
||||||
|
throw new Error(`unar failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the .bsp file in the extracted contents
|
||||||
|
const bspFile = await findBSPFile(tempExtractDir, mapName);
|
||||||
|
|
||||||
|
if (!bspFile) {
|
||||||
|
throw new Error(`No BSP file found in archive for ${mapName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the BSP file to the maps directory
|
||||||
|
await moveFile(bspFile, join(destDir, `${mapName}.bsp`));
|
||||||
|
|
||||||
|
// Cleanup temp extraction directory
|
||||||
|
await Deno.remove(tempExtractDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractZIP(archivePath: string, mapName: string, destDir: string): Promise<void> {
|
||||||
|
// Use system unzip command
|
||||||
|
const tempExtractDir = join(CONFIG.tempDir, `extract_${mapName}`);
|
||||||
|
await Deno.mkdir(tempExtractDir, { recursive: true });
|
||||||
|
|
||||||
|
const process = new Deno.Command("unzip", {
|
||||||
|
args: ["-o", archivePath, "-d", tempExtractDir],
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, stderr } = await process.output();
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errorMsg = new TextDecoder().decode(stderr);
|
||||||
|
throw new Error(`unzip failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the .bsp file
|
||||||
|
const bspFile = await findBSPFile(tempExtractDir, mapName);
|
||||||
|
|
||||||
|
if (!bspFile) {
|
||||||
|
throw new Error(`No BSP file found in archive for ${mapName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the BSP file to the maps directory
|
||||||
|
await moveFile(bspFile, join(destDir, `${mapName}.bsp`));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await Deno.remove(tempExtractDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decompressBZ2(archivePath: string, outputPath: string): Promise<void> {
|
||||||
|
// Use system bunzip2 command for decompression
|
||||||
|
const process = new Deno.Command("bunzip2", {
|
||||||
|
args: ["-c", archivePath],
|
||||||
|
stdout: "piped",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, stdout } = await process.output();
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(`bunzip2 failed with exit code ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Deno.writeFile(outputPath, stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findBSPFile(dir: string, mapName: string): Promise<string | null> {
|
||||||
|
// Recursively search for .bsp file matching the map name
|
||||||
|
for await (const entry of Deno.readDir(dir)) {
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isFile && entry.name.endsWith(".bsp")) {
|
||||||
|
// Check if filename matches (case-insensitive)
|
||||||
|
const baseName = entry.name.replace(/\.bsp$/i, "").toLowerCase();
|
||||||
|
if (baseName === mapName.toLowerCase()) {
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
// Recursively search subdirectories
|
||||||
|
const found = await findBSPFile(fullPath, mapName);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyBSP(bspPath: string): Promise<void> {
|
||||||
|
const file = await Deno.open(bspPath, { read: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = new Uint8Array(4);
|
||||||
|
await file.read(buffer);
|
||||||
|
|
||||||
|
const signature = new TextDecoder().decode(buffer);
|
||||||
|
|
||||||
|
if (signature !== "VBSP") {
|
||||||
|
throw new Error(`Invalid BSP signature: ${signature}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (should be at least 1MB for a valid surf map)
|
||||||
|
const stat = await Deno.stat(bspPath);
|
||||||
|
if (stat.size < 1024 * 1024) {
|
||||||
|
throw new Error("BSP file too small - possibly corrupted");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
async function main() {
|
||||||
|
const args = parse(Deno.args);
|
||||||
|
|
||||||
|
console.log("[*] Surf Map Downloader\n");
|
||||||
|
console.log("━".repeat(60));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Download and parse SQL
|
||||||
|
const sqlContent = await downloadSurfZonesSQL();
|
||||||
|
const requiredMaps = extractMapNamesFromSQL(sqlContent);
|
||||||
|
|
||||||
|
// Step 2: Get local maps
|
||||||
|
const localMaps = await getLocalMaps();
|
||||||
|
|
||||||
|
// Step 3: Build manifest
|
||||||
|
const manifest = buildManifest(requiredMaps, localMaps);
|
||||||
|
|
||||||
|
console.log("\n[INFO] Summary:");
|
||||||
|
console.log(` Total maps in SQL: ${manifest.total}`);
|
||||||
|
console.log(` Already downloaded: ${manifest.found}`);
|
||||||
|
console.log(` Missing: ${manifest.missing.length}`);
|
||||||
|
|
||||||
|
if (manifest.missing.length === 0) {
|
||||||
|
console.log("\n[*] All maps are already downloaded!");
|
||||||
|
Deno.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 & 5: Search Google Drive
|
||||||
|
const client = new GoogleDriveClient();
|
||||||
|
await client.authenticate();
|
||||||
|
|
||||||
|
const mapsToDownload = await searchGDriveForMaps(client, manifest);
|
||||||
|
|
||||||
|
if (mapsToDownload.size === 0) {
|
||||||
|
console.log("\n[!] No maps available to download from Google Drive");
|
||||||
|
Deno.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dry run check
|
||||||
|
if (args["dry-run"]) {
|
||||||
|
console.log("\n[END] Dry run complete. Use without --dry-run to download.");
|
||||||
|
Deno.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Download maps
|
||||||
|
const stats = await downloadAndExtractMaps(client, mapsToDownload);
|
||||||
|
|
||||||
|
// Final report
|
||||||
|
console.log("\n" + "━".repeat(60));
|
||||||
|
console.log("[OK] Download Complete!\n");
|
||||||
|
console.log(`[DL] Successfully downloaded: ${stats.success} maps`);
|
||||||
|
console.log(`[X] Failed: ${stats.failed} maps`);
|
||||||
|
console.log(`[!] Not in Google Drive: ${manifest.notInGDrive.length} maps`);
|
||||||
|
console.log("━".repeat(60));
|
||||||
|
|
||||||
|
Deno.exit(stats.failed > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\n[X] Fatal error: ${(error as Error).message}`);
|
||||||
|
if (args.verbose) {
|
||||||
|
console.error((error as Error).stack);
|
||||||
|
}
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if executed directly
|
||||||
|
if (import.meta.main) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@ -3,20 +3,83 @@
|
|||||||
// this script should be run with deno
|
// this script should be run with deno
|
||||||
// use async stdlib fns where possible
|
// use async stdlib fns where possible
|
||||||
|
|
||||||
// Polyfill Node.js globals for googleapis
|
|
||||||
import process from "node:process";
|
|
||||||
if (!globalThis.process) {
|
|
||||||
globalThis.process = process;
|
|
||||||
}
|
|
||||||
|
|
||||||
import { parse } from "https://deno.land/std@0.210.0/flags/mod.ts";
|
import { parse } from "https://deno.land/std@0.210.0/flags/mod.ts";
|
||||||
import { join } from "https://deno.land/std@0.210.0/path/mod.ts";
|
import { join } from "https://deno.land/std@0.210.0/path/mod.ts";
|
||||||
import { google } from "npm:googleapis@131";
|
import { type CredentialsClient, Drive } from "https://googleapis.deno.dev/v1/drive:v3.ts";
|
||||||
import { GoogleAuth } from "npm:google-auth-library@9";
|
import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.14.4/index.ts";
|
||||||
|
|
||||||
|
// Service account auth that exchanges JWT for an OAuth2 access token
|
||||||
|
class ServiceAccountAuth implements CredentialsClient {
|
||||||
|
#clientEmail: string;
|
||||||
|
#privateKey: string;
|
||||||
|
#tokenUri: string;
|
||||||
|
#scope: string;
|
||||||
|
#accessToken: string | null = null;
|
||||||
|
#tokenExpiry = 0;
|
||||||
|
|
||||||
|
constructor(json: { client_email: string; private_key: string; token_uri?: string }, scope: string) {
|
||||||
|
this.#clientEmail = json.client_email;
|
||||||
|
this.#privateKey = json.private_key;
|
||||||
|
this.#tokenUri = json.token_uri ?? "https://oauth2.googleapis.com/token";
|
||||||
|
this.#scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRequestHeaders(_url?: string): Promise<Record<string, string>> {
|
||||||
|
if (!this.#accessToken || Date.now() / 1000 > this.#tokenExpiry - 60) {
|
||||||
|
await this.#refreshToken();
|
||||||
|
}
|
||||||
|
return { Authorization: `Bearer ${this.#accessToken}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async #refreshToken(): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const key = await importPKCS8(this.#privateKey, "RS256");
|
||||||
|
const assertion = await new SignJWT({
|
||||||
|
iss: this.#clientEmail,
|
||||||
|
scope: this.#scope,
|
||||||
|
aud: this.#tokenUri,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpirationTime(now + 3600)
|
||||||
|
.sign(key);
|
||||||
|
|
||||||
|
const resp = await fetch(this.#tokenUri, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||||
|
assertion,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`Token exchange failed (${resp.status}): ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
this.#accessToken = data.access_token;
|
||||||
|
this.#tokenExpiry = now + (data.expires_in ?? 3600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
sqlUrl: "https://raw.githubusercontent.com/bhopppp/Shavit-Surf-Timer/master/sql/surfzones.sql",
|
maps: [
|
||||||
|
"surf_utopia_njv",
|
||||||
|
"surf_kitsune",
|
||||||
|
"surf_lt_omnific",
|
||||||
|
"surf_pantheon",
|
||||||
|
"surf_kitsune2",
|
||||||
|
"surf_kepler",
|
||||||
|
"surf_anzchamps",
|
||||||
|
"surf_deathstar",
|
||||||
|
"surf_ebony",
|
||||||
|
"surf_fornax",
|
||||||
|
"surf_garden",
|
||||||
|
"surf_in_space",
|
||||||
|
],
|
||||||
mapsDir: Deno.env.get("MAPS_DIR") || "/home/ntr/surf_megastructure/data/cssds/cstrike/maps",
|
mapsDir: Deno.env.get("MAPS_DIR") || "/home/ntr/surf_megastructure/data/cssds/cstrike/maps",
|
||||||
tempDir: Deno.env.get("TEMP_DIR") || "/tmp/mapsdl",
|
tempDir: Deno.env.get("TEMP_DIR") || "/tmp/mapsdl",
|
||||||
serviceAccountPath: Deno.env.get("GOOGLE_APPLICATION_CREDENTIALS") || "./credentials/service-account.json",
|
serviceAccountPath: Deno.env.get("GOOGLE_APPLICATION_CREDENTIALS") || "./credentials/service-account.json",
|
||||||
@ -40,46 +103,7 @@ interface Manifest {
|
|||||||
notInGDrive: string[];
|
notInGDrive: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Download and parse SQL file for map names
|
// Step 1: Check which maps exist locally
|
||||||
async function downloadSurfZonesSQL(): Promise<string> {
|
|
||||||
console.log(`[DL] Downloading SQL file from ${CONFIG.sqlUrl}...`);
|
|
||||||
const response = await fetch(CONFIG.sqlUrl);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download SQL: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
return await response.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractMapNamesFromSQL(sqlContent: string): Set<string> {
|
|
||||||
console.log("[>>] Extracting map names from SQL...");
|
|
||||||
|
|
||||||
const mapNames = new Set<string>();
|
|
||||||
|
|
||||||
// Match INSERT statements and extract map names (surf_*)
|
|
||||||
// Handles both single and multi-value INSERT statements
|
|
||||||
const insertRegex = /INSERT INTO\s+`?(?:map)?zones`?\s+.*?VALUES\s*\((.*?)\);/gis;
|
|
||||||
|
|
||||||
for (const match of sqlContent.matchAll(insertRegex)) {
|
|
||||||
const valuesSection = match[1];
|
|
||||||
|
|
||||||
// Extract all string literals from the VALUES section
|
|
||||||
const stringRegex = /'([^']+)'/g;
|
|
||||||
|
|
||||||
for (const stringMatch of valuesSection.matchAll(stringRegex)) {
|
|
||||||
const value = stringMatch[1];
|
|
||||||
|
|
||||||
// Only keep values that start with 'surf_'
|
|
||||||
if (value.startsWith("surf_")) {
|
|
||||||
mapNames.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[OK] Found ${mapNames.size} unique surf maps in SQL`);
|
|
||||||
return mapNames;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Check which maps exist locally
|
|
||||||
async function getLocalMaps(): Promise<Set<string>> {
|
async function getLocalMaps(): Promise<Set<string>> {
|
||||||
console.log(`[DIR] Scanning local maps directory: ${CONFIG.mapsDir}`);
|
console.log(`[DIR] Scanning local maps directory: ${CONFIG.mapsDir}`);
|
||||||
|
|
||||||
@ -129,68 +153,57 @@ function buildManifest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Google Drive API client with service account
|
// Google Drive API client with service account
|
||||||
class GoogleDriveClient {
|
class GoogleDriveClient {
|
||||||
private drive: any;
|
private drive!: Drive;
|
||||||
|
private auth!: CredentialsClient;
|
||||||
|
|
||||||
async authenticate(): Promise<void> {
|
async authenticate(): Promise<void> {
|
||||||
console.log("[AUTH] Authenticating with Google Drive API...");
|
console.log("[AUTH] Authenticating with Google Drive API...");
|
||||||
|
|
||||||
const auth = new GoogleAuth({
|
const keyFile = await Deno.readTextFile(CONFIG.serviceAccountPath);
|
||||||
keyFile: CONFIG.serviceAccountPath,
|
this.auth = new ServiceAccountAuth(
|
||||||
scopes: ["https://www.googleapis.com/auth/drive.readonly"],
|
JSON.parse(keyFile),
|
||||||
});
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
|
);
|
||||||
this.drive = google.drive({ version: "v3", auth });
|
this.drive = new Drive(this.auth);
|
||||||
|
|
||||||
console.log("[OK] Authentication successful");
|
console.log("[OK] Authentication successful");
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFiles(folderId: string): Promise<GDriveFile[]> {
|
async listFiles(folderId: string): Promise<GDriveFile[]> {
|
||||||
const files: GDriveFile[] = [];
|
const files: GDriveFile[] = [];
|
||||||
let pageToken: string | null = null;
|
let pageToken: string | undefined;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const response = await this.drive.files.list({
|
const response = await this.drive.filesList({
|
||||||
q: `'${folderId}' in parents and trashed=false`,
|
q: `'${folderId}' in parents and trashed=false`,
|
||||||
fields: "nextPageToken, files(id, name, size)",
|
|
||||||
pageSize: 1000,
|
pageSize: 1000,
|
||||||
pageToken: pageToken || undefined,
|
pageToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
files.push(...(response.data.files || []));
|
for (const f of response.files ?? []) {
|
||||||
pageToken = response.data.nextPageToken || null;
|
if (f.id && f.name) {
|
||||||
|
files.push({ id: f.id, name: f.name, size: String(f.size ?? "") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageToken = response.nextPageToken ?? undefined;
|
||||||
} while (pageToken);
|
} while (pageToken);
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile(fileId: string, destPath: string): Promise<void> {
|
async downloadFile(fileId: string, destPath: string): Promise<void> {
|
||||||
const response = await this.drive.files.get(
|
const url = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`;
|
||||||
{ fileId, alt: "media" },
|
const headers = await this.auth.getRequestHeaders(url);
|
||||||
{ responseType: "stream" }
|
const response = await fetch(url, { headers });
|
||||||
);
|
|
||||||
|
|
||||||
const file = await Deno.open(destPath, { create: true, write: true });
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Convert Node.js stream to web stream for Deno
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
const webStream = new ReadableStream({
|
await Deno.writeFile(destPath, bytes);
|
||||||
async start(controller) {
|
|
||||||
response.data.on("data", (chunk: Uint8Array) => {
|
|
||||||
controller.enqueue(chunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
response.data.on("end", () => {
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
response.data.on("error", (err: Error) => {
|
|
||||||
controller.error(err);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await webStream.pipeTo(file.writable);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,7 +249,7 @@ async function searchGDriveForMaps(
|
|||||||
console.log(` - "${name}"`);
|
console.log(` - "${name}"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("\n[>>] Debug - First 5 SQL maps:");
|
console.log("\n[>>] Debug - First 5 wishlist maps:");
|
||||||
manifest.missing.slice(0, 5).forEach(name => {
|
manifest.missing.slice(0, 5).forEach(name => {
|
||||||
console.log(` - "${name.toLowerCase().trim()}"`);
|
console.log(` - "${name.toLowerCase().trim()}"`);
|
||||||
});
|
});
|
||||||
@ -272,6 +285,20 @@ async function searchGDriveForMaps(
|
|||||||
return availableMaps;
|
return availableMaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-device safe move (copy + delete)
|
||||||
|
async function moveFile(src: string, dest: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Deno.rename(src, dest);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).message.includes("os error 18")) {
|
||||||
|
await Deno.copyFile(src, dest);
|
||||||
|
await Deno.remove(src);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 6: Download and extract maps
|
// Step 6: Download and extract maps
|
||||||
async function downloadAndExtractMaps(
|
async function downloadAndExtractMaps(
|
||||||
client: GoogleDriveClient,
|
client: GoogleDriveClient,
|
||||||
@ -315,7 +342,7 @@ async function downloadAndExtractMaps(
|
|||||||
await Deno.remove(archivePath); // Cleanup archive
|
await Deno.remove(archivePath); // Cleanup archive
|
||||||
} else if (gdriveFile.name.endsWith(".bsp")) {
|
} else if (gdriveFile.name.endsWith(".bsp")) {
|
||||||
// If already a BSP, just move it
|
// If already a BSP, just move it
|
||||||
await Deno.rename(archivePath, bspPath);
|
await moveFile(archivePath, bspPath);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown archive format: ${gdriveFile.name}`);
|
throw new Error(`Unknown archive format: ${gdriveFile.name}`);
|
||||||
}
|
}
|
||||||
@ -327,7 +354,7 @@ async function downloadAndExtractMaps(
|
|||||||
console.log(` [OK] ${mapName} downloaded and extracted`);
|
console.log(` [OK] ${mapName} downloaded and extracted`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stats.failed++;
|
stats.failed++;
|
||||||
console.error(` [X] Failed to download ${mapName}: ${error.message}`);
|
console.error(` [X] Failed to download ${mapName}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -341,8 +368,8 @@ async function extractRAR(archivePath: string, mapName: string, destDir: string)
|
|||||||
const tempExtractDir = join(CONFIG.tempDir, `extract_${mapName}`);
|
const tempExtractDir = join(CONFIG.tempDir, `extract_${mapName}`);
|
||||||
await Deno.mkdir(tempExtractDir, { recursive: true });
|
await Deno.mkdir(tempExtractDir, { recursive: true });
|
||||||
|
|
||||||
const process = new Deno.Command("unrar", {
|
const process = new Deno.Command("unar", {
|
||||||
args: ["x", "-o+", archivePath, tempExtractDir],
|
args: ["-f", "-o", tempExtractDir, archivePath],
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
});
|
});
|
||||||
@ -351,7 +378,7 @@ async function extractRAR(archivePath: string, mapName: string, destDir: string)
|
|||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const errorMsg = new TextDecoder().decode(stderr);
|
const errorMsg = new TextDecoder().decode(stderr);
|
||||||
throw new Error(`unrar failed: ${errorMsg}`);
|
throw new Error(`unar failed: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the .bsp file in the extracted contents
|
// Find the .bsp file in the extracted contents
|
||||||
@ -362,7 +389,7 @@ async function extractRAR(archivePath: string, mapName: string, destDir: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move the BSP file to the maps directory
|
// Move the BSP file to the maps directory
|
||||||
await Deno.rename(bspFile, join(destDir, `${mapName}.bsp`));
|
await moveFile(bspFile, join(destDir, `${mapName}.bsp`));
|
||||||
|
|
||||||
// Cleanup temp extraction directory
|
// Cleanup temp extraction directory
|
||||||
await Deno.remove(tempExtractDir, { recursive: true });
|
await Deno.remove(tempExtractDir, { recursive: true });
|
||||||
@ -394,7 +421,7 @@ async function extractZIP(archivePath: string, mapName: string, destDir: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move the BSP file to the maps directory
|
// Move the BSP file to the maps directory
|
||||||
await Deno.rename(bspFile, join(destDir, `${mapName}.bsp`));
|
await moveFile(bspFile, join(destDir, `${mapName}.bsp`));
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await Deno.remove(tempExtractDir, { recursive: true });
|
await Deno.remove(tempExtractDir, { recursive: true });
|
||||||
@ -468,9 +495,9 @@ async function main() {
|
|||||||
console.log("━".repeat(60));
|
console.log("━".repeat(60));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Download and parse SQL
|
// Step 1: Load wishlist
|
||||||
const sqlContent = await downloadSurfZonesSQL();
|
const requiredMaps = new Set(CONFIG.maps);
|
||||||
const requiredMaps = extractMapNamesFromSQL(sqlContent);
|
console.log(`[OK] Wishlist: ${requiredMaps.size} maps`);
|
||||||
|
|
||||||
// Step 2: Get local maps
|
// Step 2: Get local maps
|
||||||
const localMaps = await getLocalMaps();
|
const localMaps = await getLocalMaps();
|
||||||
@ -479,7 +506,7 @@ async function main() {
|
|||||||
const manifest = buildManifest(requiredMaps, localMaps);
|
const manifest = buildManifest(requiredMaps, localMaps);
|
||||||
|
|
||||||
console.log("\n[INFO] Summary:");
|
console.log("\n[INFO] Summary:");
|
||||||
console.log(` Total maps in SQL: ${manifest.total}`);
|
console.log(` Total maps in wishlist: ${manifest.total}`);
|
||||||
console.log(` Already downloaded: ${manifest.found}`);
|
console.log(` Already downloaded: ${manifest.found}`);
|
||||||
console.log(` Missing: ${manifest.missing.length}`);
|
console.log(` Missing: ${manifest.missing.length}`);
|
||||||
|
|
||||||
@ -519,9 +546,9 @@ async function main() {
|
|||||||
Deno.exit(stats.failed > 0 ? 1 : 0);
|
Deno.exit(stats.failed > 0 ? 1 : 0);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`\n[X] Fatal error: ${error.message}`);
|
console.error(`\n[X] Fatal error: ${(error as Error).message}`);
|
||||||
if (args.verbose) {
|
if (args.verbose) {
|
||||||
console.error(error.stack);
|
console.error((error as Error).stack);
|
||||||
}
|
}
|
||||||
Deno.exit(1);
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user