-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Basic integration with Nexus Mods API for auto-updating mods
- Loading branch information
Showing
17 changed files
with
761 additions
and
78 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
export type ModUpdaterNexusDownload = { | ||
type: 'nexus'; | ||
version: string; | ||
modID: string; | ||
fileID: number; | ||
}; | ||
|
||
export type ModUpdaterDownload = ModUpdaterNexusDownload; | ||
|
||
export type IModUpdaterAPI = { | ||
getDownloadsViaNexus: ( | ||
nexusApiKey: string, | ||
nexusModID: string, | ||
) => Promise<ModUpdaterDownload[]>; | ||
installModViaNexus: ( | ||
modID: string, | ||
nexusApiKey: string, | ||
nexusModID: string, | ||
nexusFileID: number, | ||
// when installing from a .nxm link | ||
key?: string, | ||
expires?: number, | ||
) => Promise<void>; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
// /v1/games/{game_domain_name}/mods/{id}.json | ||
// Retrieve specified mod, from a specified game | ||
export type Mod = { | ||
name: string; | ||
summary: string; | ||
description: string; | ||
picture_url: string; | ||
mod_downloads: number; | ||
mod_unique_downloads: number; | ||
uid: number; | ||
mod_id: number; | ||
game_id: number; | ||
allow_rating: boolean; | ||
domain_name: string; | ||
category_id: number; | ||
version: string; | ||
endorsement_count: number; | ||
created_timestamp: number; | ||
created_time: string; | ||
updated_timestamp: number; | ||
updated_time: string; | ||
author: string; | ||
uploaded_by: string; | ||
uploaded_users_profile_url: string; | ||
contains_adult_content: boolean; | ||
status: string; | ||
available: boolean; | ||
user: { | ||
member_id: number; | ||
member_group_id: number; | ||
name: string; | ||
}; | ||
endorsement: { | ||
endorse_status: string; | ||
timestamp: number | null; | ||
version: string | null; | ||
}; | ||
}; | ||
|
||
export type File = { | ||
category_id: number; | ||
category_name: string; | ||
changelog_html: string; | ||
content_preview_link: string; | ||
description: string; | ||
external_virus_scan_url: string; | ||
file_id: number; | ||
file_name: string; | ||
id: number[]; | ||
is_primary: boolean; | ||
mod_version: string; | ||
name: string; | ||
size: number; | ||
size_in_bytes: number; | ||
size_kb: number; | ||
uid: number; | ||
uploaded_time: string; | ||
uploaded_timestamp: number; | ||
version: string; | ||
}; | ||
|
||
export type FileUpdate = { | ||
new_file_id: number; | ||
new_file_name: string; | ||
old_file_id: number; | ||
old_file_name: string; | ||
uploaded_time: string; | ||
uploaded_timestamp: number; | ||
}; | ||
|
||
// /v1/games/{game_domain_name}/mods/{mod_id}/files.json | ||
// List files for specified mod | ||
export type Files = { | ||
files: File[]; | ||
file_updates: FileUpdate[]; | ||
}; | ||
|
||
// /v1/games/{game_domain_name}/mods/{mod_id}/files/{id}/download_link.json | ||
// Generate download link for mod file | ||
export type DownloadLink = { | ||
name: string; | ||
short_name: string; | ||
URI: string; | ||
}[]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,17 @@ | ||
export type RequestHeaders = Record<string, string>; | ||
|
||
export type ResponseHeaders = Record<string, string | string[]>; | ||
|
||
export type IRequestAPI = { | ||
download: ( | ||
url: string, | ||
fileName?: string | null, | ||
eventID?: string | null, | ||
) => Promise<string>; | ||
options?: { | ||
fileName?: string | null; | ||
eventID?: string | null; | ||
headers?: RequestHeaders | null; | ||
} | null, | ||
) => Promise<{ | ||
filePath: string; | ||
headers: ResponseHeaders; | ||
}>; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
import decompress from 'decompress'; | ||
import { cpSync, existsSync, mkdirSync, rmSync } from 'fs'; | ||
import path from 'path'; | ||
import type { IModUpdaterAPI } from 'bridge/ModUpdaterAPI'; | ||
import type { DownloadLink, Files } from 'bridge/NexusModsAPI'; | ||
import type { ResponseHeaders } from 'bridge/RequestAPI'; | ||
import { getAppPath } from './AppInfoAPI'; | ||
import { EventAPI } from './EventAPI'; | ||
import { provideAPI } from './IPC'; | ||
import { RequestAPI } from './RequestAPI'; | ||
|
||
// TODO: publish status of update checking / downloading / installing for nice UX | ||
|
||
const NexusAPI = { | ||
publishStatus: async (headers: ResponseHeaders): Promise<void> => { | ||
// TODO: add Nexus API rate limiting status to the UI somewhere | ||
await EventAPI.send('nexus-mods-api-status', { | ||
dailyLimit: headers['x-rl-daily-limit'], | ||
dailyRemaining: headers['x-rl-daily-remaining'], | ||
dailyReset: headers['x-rl-daily-reset'], | ||
hourlyLimit: headers['x-rl-hourly-limit'], | ||
hourlyRemaining: headers['x-rl-hourly-remaining'], | ||
hourlyReset: headers['x-rl-hourly-reset'], | ||
}); | ||
}, | ||
getFiles: async (nexusApiKey: string, nexusModID: string): Promise<Files> => { | ||
const { response, headers } = await RequestAPI.downloadToBuffer( | ||
`https://api.nexusmods.com/v1/games/diablo2resurrected/mods/${nexusModID}/files.json`, | ||
{ | ||
headers: { | ||
accept: 'application/json', | ||
apikey: nexusApiKey, | ||
}, | ||
}, | ||
); | ||
await NexusAPI.publishStatus(headers); | ||
return JSON.parse(response.toString()) as Files; | ||
}, | ||
getDownloadLink: async ( | ||
nexusApiKey: string, | ||
nexusModID: string, | ||
nexusFileID: number, | ||
key?: string, | ||
expires?: number, | ||
): Promise<DownloadLink> => { | ||
const args = | ||
key != null && expires != null ? `?key=${key}&expires=${expires}` : ''; | ||
const { response, headers } = await RequestAPI.downloadToBuffer( | ||
`https://api.nexusmods.com/v1/games/diablo2resurrected/mods/${nexusModID}/files/${nexusFileID}/download_link.json${args}`, | ||
{ | ||
headers: { | ||
accept: 'application/json', | ||
apikey: nexusApiKey, | ||
}, | ||
}, | ||
); | ||
await NexusAPI.publishStatus(headers); | ||
return JSON.parse(response.toString()) as DownloadLink; | ||
}, | ||
}; | ||
|
||
export async function initModUpdaterAPI(): Promise<void> { | ||
provideAPI('ModUpdaterAPI', { | ||
getDownloadsViaNexus: async (nexusApiKey, nexusModID) => { | ||
const result = await NexusAPI.getFiles(nexusApiKey, nexusModID); | ||
return result.files | ||
.filter( | ||
(file) => | ||
file.category_name === 'MAIN' || | ||
file.category_name === 'OLD_VERSION', | ||
) | ||
.map((file) => ({ | ||
type: 'nexus', | ||
version: file.version, | ||
modID: nexusModID, | ||
fileID: file.file_id, | ||
})); | ||
}, | ||
installModViaNexus: async ( | ||
modID, | ||
nexusApiKey, | ||
nexusModID, | ||
nexusFileID, | ||
key, | ||
expires, | ||
) => { | ||
// get link to the zip file on Nexus Mods CDN | ||
const downloadLink = await NexusAPI.getDownloadLink( | ||
nexusApiKey, | ||
nexusModID, | ||
nexusFileID, | ||
key, | ||
expires, | ||
); | ||
const downloadUrl = downloadLink[0]?.URI; | ||
if (downloadUrl == null) { | ||
throw new Error( | ||
`No download links found for Nexus mod ${nexusModID} file ${nexusFileID}.`, | ||
); | ||
} | ||
|
||
// download the zip file | ||
const fileName = `${modID}.zip`; | ||
const { filePath } = await RequestAPI.downloadToFile(downloadUrl, { | ||
fileName, | ||
}); | ||
console.log('update downloaded', nexusModID, nexusFileID, filePath); | ||
|
||
// extract the zip file | ||
process.noAsar = true; | ||
await decompress(filePath, path.dirname(filePath)); | ||
process.noAsar = false; | ||
rmSync(filePath); | ||
|
||
// delete all mod files except mod.config | ||
const updateDirPath = path.join(path.dirname(filePath), modID); | ||
console.log('update extracted', nexusModID, nexusFileID, updateDirPath); | ||
if (!existsSync(updateDirPath)) { | ||
throw new Error( | ||
`Mod has an unexpected file structure. Expected to find directory ${modID} in downloaded .zip file.`, | ||
); | ||
} | ||
const modDirPath = path.join(getAppPath(), 'mods', modID); | ||
console.log( | ||
'update ready', | ||
nexusModID, | ||
nexusFileID, | ||
updateDirPath, | ||
modDirPath, | ||
); | ||
const configFilePath = path.join(modDirPath, 'config.json'); | ||
if (existsSync(configFilePath)) { | ||
cpSync(configFilePath, path.join(updateDirPath, 'config.json')); | ||
} | ||
rmSync(modDirPath, { force: true, recursive: true }); | ||
console.log( | ||
'update prepped', | ||
nexusModID, | ||
nexusFileID, | ||
updateDirPath, | ||
modDirPath, | ||
); | ||
|
||
// copy the new extracted files to the mod directory | ||
mkdirSync(modDirPath, { recursive: true }); | ||
cpSync(updateDirPath, modDirPath, { recursive: true }); | ||
console.log('update applied', nexusModID, nexusFileID, modDirPath); | ||
|
||
// clean up | ||
rmSync(updateDirPath, { force: true, recursive: true }); | ||
}, | ||
} as IModUpdaterAPI); | ||
} |
Oops, something went wrong.