From 31a2ac82d02512d28e55366d7c1509f0f0c7d65f Mon Sep 17 00:00:00 2001 From: Nathan Rashleigh Date: Tue, 10 Feb 2026 10:07:09 +1100 Subject: [PATCH] mapsdl common --- Justfile | 14 +- src/mapsdl/Dockerfile | 8 +- src/mapsdl/common.ts | 461 +++++++++++++++++++++++++++++++++++ src/mapsdl/mapsdl-all.ts | 500 ++------------------------------------ src/mapsdl/mapsdl.ts | 507 ++------------------------------------- 5 files changed, 504 insertions(+), 986 deletions(-) create mode 100644 src/mapsdl/common.ts diff --git a/Justfile b/Justfile index dc1af45..6b0a9e6 100644 --- a/Justfile +++ b/Justfile @@ -18,13 +18,13 @@ cssds-exec: # Map downloader commands mapsdl-dry: - cd src/mapsdl && deno run --allow-net --allow-read --allow-write --allow-env --allow-sys mapsdl.ts --dry-run - -mapsdl-dry-verbose: - cd src/mapsdl && deno run --allow-net --allow-read --allow-write --allow-env --allow-sys mapsdl.ts --dry-run --verbose + cd src/mapsdl && deno run -A mapsdl.ts --dry-run mapsdl: - cd src/mapsdl && deno run --allow-net --allow-read --allow-write --allow-env --allow-sys mapsdl.ts + cd src/mapsdl && deno run -A mapsdl.ts -mapsdl-verbose: - cd src/mapsdl && deno run --allow-net --allow-read --allow-write --allow-env --allow-sys mapsdl.ts --verbose \ No newline at end of file +mapsdl-all-dry: + cd src/mapsdl && deno run -A mapsdl-all.ts --dry-run + +mapsdl-all: + cd src/mapsdl && deno run -A mapsdl-all.ts --dry-run diff --git a/src/mapsdl/Dockerfile b/src/mapsdl/Dockerfile index 653f0de..5799da8 100644 --- a/src/mapsdl/Dockerfile +++ b/src/mapsdl/Dockerfile @@ -10,8 +10,10 @@ RUN apt-get update && apt-get install -y \ # Create app directory WORKDIR /app -# Copy the script +# Copy the scripts +COPY common.ts . COPY mapsdl.ts . +COPY mapsdl-all.ts . # Run as UID 1000 to match cssds (steam user) RUN groupadd -g 1000 steam && useradd -u 1000 -g steam -m steam @@ -21,7 +23,7 @@ RUN mkdir -p /app/credentials /maps /tmp/mapsdl && \ chown steam:steam /maps /tmp/mapsdl # Cache Deno dependencies -RUN deno cache --allow-import mapsdl.ts +RUN deno cache --allow-import mapsdl.ts mapsdl-all.ts # Set default environment variables ENV MAPS_DIR=/maps @@ -32,4 +34,4 @@ ENV GOOGLE_APPLICATION_CREDENTIALS=/app/credentials/service-account.json USER steam # Default command runs dry-run mode -CMD ["run", "--allow-import", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-sys", "mapsdl.ts", "--dry-run"] +CMD ["run", "-A", "mapsdl.ts", "--dry-run"] diff --git a/src/mapsdl/common.ts b/src/mapsdl/common.ts new file mode 100644 index 0000000..620983d --- /dev/null +++ b/src/mapsdl/common.ts @@ -0,0 +1,461 @@ +import { join } from "https://deno.land/std@0.210.0/path/mod.ts"; +import { type CredentialsClient } from "https://googleapis.deno.dev/v1/drive:v3.ts"; +import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.14.4/index.ts"; + +// Types + +export interface GDriveFile { + id: string; + name: string; + size: string; +} + +export interface Manifest { + total: number; + found: number; + missing: string[]; + notInGDrive: string[]; +} + +export type GDriveFolders = { aToK: string; lToZ: string }; + +export type DownloadConfig = { mapsDir: string; tempDir: string; concurrency: number }; + +// Service account auth that exchanges JWT for an OAuth2 access token +export 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> { + if (!this.#accessToken || Date.now() / 1000 > this.#tokenExpiry - 60) { + await this.#refreshToken(); + } + return { Authorization: `Bearer ${this.#accessToken}` }; + } + + async #refreshToken(): Promise { + 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); + } +} + +// Google Drive API client with service account +export class GoogleDriveClient { + private auth!: CredentialsClient; + + async authenticate(serviceAccountPath: string): Promise { + console.log("[AUTH] Authenticating with Google Drive API..."); + + const keyFile = await Deno.readTextFile(serviceAccountPath); + this.auth = new ServiceAccountAuth( + JSON.parse(keyFile), + "https://www.googleapis.com/auth/drive.readonly", + ); + + console.log("[OK] Authentication successful"); + } + + async listFiles(folderId: string): Promise { + const files: GDriveFile[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams({ + q: `'${folderId}' in parents and trashed=false`, + fields: "nextPageToken, files(id, name, size)", + pageSize: "1000", + }); + if (pageToken) params.set("pageToken", pageToken); + + const url = `https://www.googleapis.com/drive/v3/files?${params}`; + const headers = await this.auth.getRequestHeaders(url); + const resp = await fetch(url, { headers }); + + if (!resp.ok) { + throw new Error(`Drive listFiles failed (${resp.status}): ${await resp.text()}`); + } + + const data = await resp.json(); + + for (const f of data.files ?? []) { + if (f.id && f.name) { + files.push({ id: f.id, name: f.name, size: String(f.size ?? "") }); + } + } + pageToken = data.nextPageToken ?? undefined; + } while (pageToken); + + return files; + } + + async downloadFile(fileId: string, destPath: string): Promise { + 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); + } +} + +// Helpers + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(2)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(2)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +export async function getLocalMaps(mapsDir: string): Promise> { + console.log(`[DIR] Scanning local maps directory: ${mapsDir}`); + + const localMaps = new Set(); + + try { + for await (const entry of Deno.readDir(mapsDir)) { + if (entry.isFile && entry.name.endsWith(".bsp")) { + const mapName = entry.name.replace(/\.bsp$/, ""); + localMaps.add(mapName); + } + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + console.warn(`[!] Maps directory not found: ${mapsDir}`); + await Deno.mkdir(mapsDir, { recursive: true }); + } else { + throw error; + } + } + + console.log(`[OK] Found ${localMaps.size} local maps`); + return localMaps; +} + +export function buildManifest( + requiredMaps: Set, + localMaps: Set, +): 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: [], + }; +} + +export async function searchGDriveForMaps( + client: GoogleDriveClient, + manifest: Manifest, + gdriveFolders: GDriveFolders, + verbose: boolean, +): Promise> { + console.log("\n[CLOUD] Searching Google Drive folders..."); + + const [filesAK, filesLZ] = await Promise.all([ + client.listFiles(gdriveFolders.aToK), + client.listFiles(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(); + + for (const file of allFiles) { + const normalized = file.name + .replace(/\.bsp\.bz2$/i, "") + .replace(/\.(rar|bz2|zip|bsp)$/i, "") + .toLowerCase() + .trim(); + + if (normalized.startsWith("surf_")) { + gdriveMap.set(normalized, file); + } + } + + if (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 missing 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(); + + 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); + } + } + + // Sum total download size + let totalBytes = 0; + for (const file of availableMaps.values()) { + const size = parseInt(file.size, 10); + if (!isNaN(size)) totalBytes += size; + } + + 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`); + console.log(` Total download size: ${formatBytes(totalBytes)}`); + + 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) +export async function moveFile(src: string, dest: string): Promise { + 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; + } + } +} + +export async function downloadAndExtractMaps( + client: GoogleDriveClient, + mapsToDownload: Map, + config: DownloadConfig, +): Promise<{ success: number; failed: number }> { + console.log(`\n[DL] Downloading ${mapsToDownload.size} maps...`); + + await Deno.mkdir(config.tempDir, { recursive: true }); + + const stats = { success: 0, failed: 0 }; + let completed = 0; + + 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`); + + await client.downloadFile(gdriveFile.id, archivePath); + + if (gdriveFile.name.endsWith(".rar")) { + await extractRAR(archivePath, mapName, config.mapsDir, config.tempDir); + await Deno.remove(archivePath); + } else if (gdriveFile.name.endsWith(".bz2")) { + await decompressBZ2(archivePath, bspPath); + await Deno.remove(archivePath); + } else if (gdriveFile.name.endsWith(".zip")) { + await extractZIP(archivePath, mapName, config.mapsDir, config.tempDir); + await Deno.remove(archivePath); + } else if (gdriveFile.name.endsWith(".bsp")) { + await moveFile(archivePath, bspPath); + } else { + throw new Error(`Unknown archive format: ${gdriveFile.name}`); + } + + 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; +} + +export async function extractRAR(archivePath: string, mapName: string, destDir: string, tempDir: string): Promise { + const tempExtractDir = join(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}`); + } + + const bspFile = await findBSPFile(tempExtractDir, mapName); + + if (!bspFile) { + throw new Error(`No BSP file found in archive for ${mapName}`); + } + + await moveFile(bspFile, join(destDir, `${mapName}.bsp`)); + await Deno.remove(tempExtractDir, { recursive: true }); +} + +export async function extractZIP(archivePath: string, mapName: string, destDir: string, tempDir: string): Promise { + const tempExtractDir = join(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}`); + } + + const bspFile = await findBSPFile(tempExtractDir, mapName); + + if (!bspFile) { + throw new Error(`No BSP file found in archive for ${mapName}`); + } + + await moveFile(bspFile, join(destDir, `${mapName}.bsp`)); + await Deno.remove(tempExtractDir, { recursive: true }); +} + +export async function decompressBZ2(archivePath: string, outputPath: string): Promise { + 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); +} + +export async function findBSPFile(dir: string, mapName: string): Promise { + for await (const entry of Deno.readDir(dir)) { + const fullPath = join(dir, entry.name); + + if (entry.isFile && entry.name.endsWith(".bsp")) { + const baseName = entry.name.replace(/\.bsp$/i, "").toLowerCase(); + if (baseName === mapName.toLowerCase()) { + return fullPath; + } + } else if (entry.isDirectory) { + const found = await findBSPFile(fullPath, mapName); + if (found) return found; + } + } + + return null; +} + +export async function verifyBSP(bspPath: string): Promise { + 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}`); + } + + const stat = await Deno.stat(bspPath); + if (stat.size < 1024 * 1024) { + throw new Error("BSP file too small - possibly corrupted"); + } + } finally { + file.close(); + } +} diff --git a/src/mapsdl/mapsdl-all.ts b/src/mapsdl/mapsdl-all.ts index 01a4839..0883a24 100755 --- a/src/mapsdl/mapsdl-all.ts +++ b/src/mapsdl/mapsdl-all.ts @@ -1,70 +1,12 @@ -// #!/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"; +import { + GoogleDriveClient, + getLocalMaps, + buildManifest, + searchGDriveForMaps, + downloadAndExtractMaps, +} from "./common.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> { - if (!this.#accessToken || Date.now() / 1000 > this.#tokenExpiry - 60) { - await this.#refreshToken(); - } - return { Authorization: `Bearer ${this.#accessToken}` }; - } - - async #refreshToken(): Promise { - 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", @@ -77,20 +19,6 @@ const CONFIG = { 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 { console.log(`[DL] Downloading SQL file from ${CONFIG.sqlUrl}...`); const response = await fetch(CONFIG.sqlUrl); @@ -104,21 +32,14 @@ function extractMapNamesFromSQL(sqlContent: string): Set { console.log("[>>] Extracting map names from SQL..."); const mapNames = new Set(); - - // 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); } @@ -129,391 +50,6 @@ function extractMapNamesFromSQL(sqlContent: string): Set { return mapNames; } -// Step 2: Check which maps exist locally -async function getLocalMaps(): Promise> { - console.log(`[DIR] Scanning local maps directory: ${CONFIG.mapsDir}`); - - const localMaps = new Set(); - - 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, - localMaps: Set, -): 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 { - 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 { - 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 { - 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> { - 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(); - - 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(); - - 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 { - 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, -): 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 { - // 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 { - // 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 { - // 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 { - // 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 { - 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); @@ -521,14 +57,10 @@ async function main() { 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 localMaps = await getLocalMaps(CONFIG.mapsDir); const manifest = buildManifest(requiredMaps, localMaps); console.log("\n[INFO] Summary:"); @@ -541,27 +73,27 @@ async function main() { Deno.exit(0); } - // Step 4 & 5: Search Google Drive const client = new GoogleDriveClient(); - await client.authenticate(); + await client.authenticate(CONFIG.serviceAccountPath); - const mapsToDownload = await searchGDriveForMaps(client, manifest); + const mapsToDownload = await searchGDriveForMaps(client, manifest, CONFIG.gdriveFolders, args.verbose === true); 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); + const stats = await downloadAndExtractMaps(client, mapsToDownload, { + mapsDir: CONFIG.mapsDir, + tempDir: CONFIG.tempDir, + concurrency: CONFIG.concurrency, + }); - // Final report console.log("\n" + "━".repeat(60)); console.log("[OK] Download Complete!\n"); console.log(`[DL] Successfully downloaded: ${stats.success} maps`); @@ -570,7 +102,6 @@ async function main() { 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) { @@ -580,7 +111,6 @@ async function main() { } } -// Run if executed directly if (import.meta.main) { main(); } diff --git a/src/mapsdl/mapsdl.ts b/src/mapsdl/mapsdl.ts index 85b000f..ad1b6f6 100644 --- a/src/mapsdl/mapsdl.ts +++ b/src/mapsdl/mapsdl.ts @@ -1,85 +1,14 @@ -// #!/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"; +import { + GoogleDriveClient, + getLocalMaps, + buildManifest, + searchGDriveForMaps, + downloadAndExtractMaps, +} from "./common.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> { - if (!this.#accessToken || Date.now() / 1000 > this.#tokenExpiry - 60) { - await this.#refreshToken(); - } - return { Authorization: `Bearer ${this.#accessToken}` }; - } - - async #refreshToken(): Promise { - 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 = { - 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", - ], + maps: (Deno.env.get("MAPS_WISHLIST") || "").split("\n").map(s => s.trim()).filter(Boolean), 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", @@ -90,404 +19,6 @@ const CONFIG = { concurrency: 5, }; -interface GDriveFile { - id: string; - name: string; - size: string; -} - -interface Manifest { - total: number; - found: number; - missing: string[]; - notInGDrive: string[]; -} - -// Step 1: Check which maps exist locally -async function getLocalMaps(): Promise> { - console.log(`[DIR] Scanning local maps directory: ${CONFIG.mapsDir}`); - - const localMaps = new Set(); - - 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, - localMaps: Set, -): 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 { - 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 { - 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 { - 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> { - 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(); - - 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 wishlist 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(); - - 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 { - 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, -): 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 { - // 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 { - // 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 { - // 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 { - // 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 { - 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); @@ -495,14 +26,10 @@ async function main() { console.log("━".repeat(60)); try { - // Step 1: Load wishlist const requiredMaps = new Set(CONFIG.maps); console.log(`[OK] Wishlist: ${requiredMaps.size} maps`); - // Step 2: Get local maps - const localMaps = await getLocalMaps(); - - // Step 3: Build manifest + const localMaps = await getLocalMaps(CONFIG.mapsDir); const manifest = buildManifest(requiredMaps, localMaps); console.log("\n[INFO] Summary:"); @@ -515,27 +42,27 @@ async function main() { Deno.exit(0); } - // Step 4 & 5: Search Google Drive const client = new GoogleDriveClient(); - await client.authenticate(); + await client.authenticate(CONFIG.serviceAccountPath); - const mapsToDownload = await searchGDriveForMaps(client, manifest); + const mapsToDownload = await searchGDriveForMaps(client, manifest, CONFIG.gdriveFolders, args.verbose === true); 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); + const stats = await downloadAndExtractMaps(client, mapsToDownload, { + mapsDir: CONFIG.mapsDir, + tempDir: CONFIG.tempDir, + concurrency: CONFIG.concurrency, + }); - // Final report console.log("\n" + "━".repeat(60)); console.log("[OK] Download Complete!\n"); console.log(`[DL] Successfully downloaded: ${stats.success} maps`); @@ -544,7 +71,6 @@ async function main() { 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) { @@ -554,7 +80,6 @@ async function main() { } } -// Run if executed directly if (import.meta.main) { main(); }