mapsdl common
This commit is contained in:
parent
f66c505082
commit
31a2ac82d0
14
Justfile
14
Justfile
@ -18,13 +18,13 @@ cssds-exec:
|
|||||||
|
|
||||||
# Map downloader commands
|
# Map downloader commands
|
||||||
mapsdl-dry:
|
mapsdl-dry:
|
||||||
cd src/mapsdl && deno run --allow-net --allow-read --allow-write --allow-env --allow-sys mapsdl.ts --dry-run
|
cd src/mapsdl && deno run -A 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
|
|
||||||
|
|
||||||
mapsdl:
|
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:
|
mapsdl-all-dry:
|
||||||
cd src/mapsdl && deno run --allow-net --allow-read --allow-write --allow-env --allow-sys mapsdl.ts --verbose
|
cd src/mapsdl && deno run -A mapsdl-all.ts --dry-run
|
||||||
|
|
||||||
|
mapsdl-all:
|
||||||
|
cd src/mapsdl && deno run -A mapsdl-all.ts --dry-run
|
||||||
|
|||||||
@ -10,8 +10,10 @@ RUN apt-get update && apt-get install -y \
|
|||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the script
|
# Copy the scripts
|
||||||
|
COPY common.ts .
|
||||||
COPY mapsdl.ts .
|
COPY mapsdl.ts .
|
||||||
|
COPY mapsdl-all.ts .
|
||||||
|
|
||||||
# Run as UID 1000 to match cssds (steam user)
|
# Run as UID 1000 to match cssds (steam user)
|
||||||
RUN groupadd -g 1000 steam && useradd -u 1000 -g steam -m steam
|
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
|
chown steam:steam /maps /tmp/mapsdl
|
||||||
|
|
||||||
# Cache Deno dependencies
|
# Cache Deno dependencies
|
||||||
RUN deno cache --allow-import mapsdl.ts
|
RUN deno cache --allow-import mapsdl.ts mapsdl-all.ts
|
||||||
|
|
||||||
# Set default environment variables
|
# Set default environment variables
|
||||||
ENV MAPS_DIR=/maps
|
ENV MAPS_DIR=/maps
|
||||||
@ -32,4 +34,4 @@ ENV GOOGLE_APPLICATION_CREDENTIALS=/app/credentials/service-account.json
|
|||||||
USER steam
|
USER steam
|
||||||
|
|
||||||
# Default command runs dry-run mode
|
# 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"]
|
||||||
|
|||||||
461
src/mapsdl/common.ts
Normal file
461
src/mapsdl/common.ts
Normal file
@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Drive API client with service account
|
||||||
|
export class GoogleDriveClient {
|
||||||
|
private auth!: CredentialsClient;
|
||||||
|
|
||||||
|
async authenticate(serviceAccountPath: string): Promise<void> {
|
||||||
|
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<GDriveFile[]> {
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Set<string>> {
|
||||||
|
console.log(`[DIR] Scanning local maps directory: ${mapsDir}`);
|
||||||
|
|
||||||
|
const localMaps = new Set<string>();
|
||||||
|
|
||||||
|
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<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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchGDriveForMaps(
|
||||||
|
client: GoogleDriveClient,
|
||||||
|
manifest: Manifest,
|
||||||
|
gdriveFolders: GDriveFolders,
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<Map<string, GDriveFile>> {
|
||||||
|
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<string, GDriveFile>();
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadAndExtractMaps(
|
||||||
|
client: GoogleDriveClient,
|
||||||
|
mapsToDownload: Map<string, GDriveFile>,
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await Deno.stat(bspPath);
|
||||||
|
if (stat.size < 1024 * 1024) {
|
||||||
|
throw new Error("BSP file too small - possibly corrupted");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { 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 {
|
||||||
import { type CredentialsClient, Drive } from "https://googleapis.deno.dev/v1/drive:v3.ts";
|
GoogleDriveClient,
|
||||||
import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.14.4/index.ts";
|
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<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 = {
|
const CONFIG = {
|
||||||
sqlUrl: "https://raw.githubusercontent.com/bhopppp/Shavit-Surf-Timer/master/sql/surfzones.sql",
|
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",
|
mapsDir: Deno.env.get("MAPS_DIR") || "/home/ntr/surf_megastructure/data/cssds/cstrike/maps",
|
||||||
@ -77,20 +19,6 @@ const CONFIG = {
|
|||||||
concurrency: 5,
|
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> {
|
async function downloadSurfZonesSQL(): Promise<string> {
|
||||||
console.log(`[DL] Downloading SQL file from ${CONFIG.sqlUrl}...`);
|
console.log(`[DL] Downloading SQL file from ${CONFIG.sqlUrl}...`);
|
||||||
const response = await fetch(CONFIG.sqlUrl);
|
const response = await fetch(CONFIG.sqlUrl);
|
||||||
@ -104,21 +32,14 @@ function extractMapNamesFromSQL(sqlContent: string): Set<string> {
|
|||||||
console.log("[>>] Extracting map names from SQL...");
|
console.log("[>>] Extracting map names from SQL...");
|
||||||
|
|
||||||
const mapNames = new Set<string>();
|
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;
|
const insertRegex = /INSERT INTO\s+`?(?:map)?zones`?\s+.*?VALUES\s*\((.*?)\);/gis;
|
||||||
|
|
||||||
for (const match of sqlContent.matchAll(insertRegex)) {
|
for (const match of sqlContent.matchAll(insertRegex)) {
|
||||||
const valuesSection = match[1];
|
const valuesSection = match[1];
|
||||||
|
|
||||||
// Extract all string literals from the VALUES section
|
|
||||||
const stringRegex = /'([^']+)'/g;
|
const stringRegex = /'([^']+)'/g;
|
||||||
|
|
||||||
for (const stringMatch of valuesSection.matchAll(stringRegex)) {
|
for (const stringMatch of valuesSection.matchAll(stringRegex)) {
|
||||||
const value = stringMatch[1];
|
const value = stringMatch[1];
|
||||||
|
|
||||||
// Only keep values that start with 'surf_'
|
|
||||||
if (value.startsWith("surf_")) {
|
if (value.startsWith("surf_")) {
|
||||||
mapNames.add(value);
|
mapNames.add(value);
|
||||||
}
|
}
|
||||||
@ -129,391 +50,6 @@ function extractMapNamesFromSQL(sqlContent: string): Set<string> {
|
|||||||
return mapNames;
|
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() {
|
async function main() {
|
||||||
const args = parse(Deno.args);
|
const args = parse(Deno.args);
|
||||||
|
|
||||||
@ -521,14 +57,10 @@ async function main() {
|
|||||||
console.log("━".repeat(60));
|
console.log("━".repeat(60));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Download and parse SQL
|
|
||||||
const sqlContent = await downloadSurfZonesSQL();
|
const sqlContent = await downloadSurfZonesSQL();
|
||||||
const requiredMaps = extractMapNamesFromSQL(sqlContent);
|
const requiredMaps = extractMapNamesFromSQL(sqlContent);
|
||||||
|
|
||||||
// Step 2: Get local maps
|
const localMaps = await getLocalMaps(CONFIG.mapsDir);
|
||||||
const localMaps = await getLocalMaps();
|
|
||||||
|
|
||||||
// Step 3: Build manifest
|
|
||||||
const manifest = buildManifest(requiredMaps, localMaps);
|
const manifest = buildManifest(requiredMaps, localMaps);
|
||||||
|
|
||||||
console.log("\n[INFO] Summary:");
|
console.log("\n[INFO] Summary:");
|
||||||
@ -541,27 +73,27 @@ async function main() {
|
|||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4 & 5: Search Google Drive
|
|
||||||
const client = new GoogleDriveClient();
|
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) {
|
if (mapsToDownload.size === 0) {
|
||||||
console.log("\n[!] No maps available to download from Google Drive");
|
console.log("\n[!] No maps available to download from Google Drive");
|
||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dry run check
|
|
||||||
if (args["dry-run"]) {
|
if (args["dry-run"]) {
|
||||||
console.log("\n[END] Dry run complete. Use without --dry-run to download.");
|
console.log("\n[END] Dry run complete. Use without --dry-run to download.");
|
||||||
Deno.exit(0);
|
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("\n" + "━".repeat(60));
|
||||||
console.log("[OK] Download Complete!\n");
|
console.log("[OK] Download Complete!\n");
|
||||||
console.log(`[DL] Successfully downloaded: ${stats.success} maps`);
|
console.log(`[DL] Successfully downloaded: ${stats.success} maps`);
|
||||||
@ -570,7 +102,6 @@ async function main() {
|
|||||||
console.log("━".repeat(60));
|
console.log("━".repeat(60));
|
||||||
|
|
||||||
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 as Error).message}`);
|
console.error(`\n[X] Fatal error: ${(error as Error).message}`);
|
||||||
if (args.verbose) {
|
if (args.verbose) {
|
||||||
@ -580,7 +111,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run if executed directly
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main();
|
main();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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 {
|
||||||
import { type CredentialsClient, Drive } from "https://googleapis.deno.dev/v1/drive:v3.ts";
|
GoogleDriveClient,
|
||||||
import { SignJWT, importPKCS8 } from "https://deno.land/x/jose@v4.14.4/index.ts";
|
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<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 = {
|
const CONFIG = {
|
||||||
maps: [
|
maps: (Deno.env.get("MAPS_WISHLIST") || "").split("\n").map(s => s.trim()).filter(Boolean),
|
||||||
"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",
|
||||||
@ -90,404 +19,6 @@ const CONFIG = {
|
|||||||
concurrency: 5,
|
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<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 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<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() {
|
async function main() {
|
||||||
const args = parse(Deno.args);
|
const args = parse(Deno.args);
|
||||||
|
|
||||||
@ -495,14 +26,10 @@ async function main() {
|
|||||||
console.log("━".repeat(60));
|
console.log("━".repeat(60));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Load wishlist
|
|
||||||
const requiredMaps = new Set(CONFIG.maps);
|
const requiredMaps = new Set(CONFIG.maps);
|
||||||
console.log(`[OK] Wishlist: ${requiredMaps.size} maps`);
|
console.log(`[OK] Wishlist: ${requiredMaps.size} maps`);
|
||||||
|
|
||||||
// Step 2: Get local maps
|
const localMaps = await getLocalMaps(CONFIG.mapsDir);
|
||||||
const localMaps = await getLocalMaps();
|
|
||||||
|
|
||||||
// Step 3: Build manifest
|
|
||||||
const manifest = buildManifest(requiredMaps, localMaps);
|
const manifest = buildManifest(requiredMaps, localMaps);
|
||||||
|
|
||||||
console.log("\n[INFO] Summary:");
|
console.log("\n[INFO] Summary:");
|
||||||
@ -515,27 +42,27 @@ async function main() {
|
|||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4 & 5: Search Google Drive
|
|
||||||
const client = new GoogleDriveClient();
|
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) {
|
if (mapsToDownload.size === 0) {
|
||||||
console.log("\n[!] No maps available to download from Google Drive");
|
console.log("\n[!] No maps available to download from Google Drive");
|
||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dry run check
|
|
||||||
if (args["dry-run"]) {
|
if (args["dry-run"]) {
|
||||||
console.log("\n[END] Dry run complete. Use without --dry-run to download.");
|
console.log("\n[END] Dry run complete. Use without --dry-run to download.");
|
||||||
Deno.exit(0);
|
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("\n" + "━".repeat(60));
|
||||||
console.log("[OK] Download Complete!\n");
|
console.log("[OK] Download Complete!\n");
|
||||||
console.log(`[DL] Successfully downloaded: ${stats.success} maps`);
|
console.log(`[DL] Successfully downloaded: ${stats.success} maps`);
|
||||||
@ -544,7 +71,6 @@ async function main() {
|
|||||||
console.log("━".repeat(60));
|
console.log("━".repeat(60));
|
||||||
|
|
||||||
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 as Error).message}`);
|
console.error(`\n[X] Fatal error: ${(error as Error).message}`);
|
||||||
if (args.verbose) {
|
if (args.verbose) {
|
||||||
@ -554,7 +80,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run if executed directly
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main();
|
main();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user