mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 06:00:21 +02:00
Resume interrupted downloads across a reboot
An interrupted download (cancel excepted) now keeps its partial package on disk instead of deleting it, and records the manifest it belongs to in a sidecar (state.json). On the next start the title is flagged resumable and the UI shows a "Paused — X downloaded" note with a Resume button. Resume refetches the manifest, skips every piece already fully on disk, and re-fetches only the one partially-written piece (piece-granular, no HTTP range needed), appending the rest. The fresh-download path is unchanged. A partial belonging to a different/older manifest is dropped and the download starts clean; a corrupt (failed SHA-256) download is not kept. Survives a reboot: a killed payload runs no cleanup, so the partial and its sidecar persist under /data/patchdl until resumed, completed, or deleted.
This commit is contained in:
1 parent
3f40dc1d7c
commit
66e4912485
5 files changed
+234
-46
No files matched your search
+56
-29
@@ -504,15 +504,13 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
const char *dest_path,
|
||||
long long *bytes_out,
|
||||
patchdl_download_progress_cb cb,
|
||||
void *ctx, int verify) {
|
||||
void *ctx, int verify, int resume) {
|
||||
patchdl_buf_t manifest;
|
||||
const char *pieces;
|
||||
const char *p;
|
||||
FILE *fp;
|
||||
long long total = 0;
|
||||
const char *pieces, *pieces_end, *p;
|
||||
FILE *fp = NULL;
|
||||
long long total = 0, have = 0;
|
||||
unsigned long long manifest_total = 0;
|
||||
int count = 0;
|
||||
int rc = -1;
|
||||
int count = 0, started, rc = -1;
|
||||
|
||||
if (bytes_out) *bytes_out = 0;
|
||||
if (patchdl_http_get(manifest_url, &manifest))
|
||||
@@ -529,14 +527,20 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
}
|
||||
/* Bound the scan to the pieces array; otherwise a later "url" key in the
|
||||
manifest (e.g. playgoChunkCrcUrl) could be appended as a bogus piece. */
|
||||
const char *pieces_end = strchr(pieces, ']');
|
||||
pieces_end = strchr(pieces, ']');
|
||||
json_u64_after(manifest.data, "originalFileSize", &manifest_total);
|
||||
|
||||
fp = fopen(dest_path, "wb");
|
||||
if (!fp) {
|
||||
free(manifest.data);
|
||||
return -1;
|
||||
/* Resume: reopen the existing partial and keep its bytes; else start clean.
|
||||
Pieces already fully on disk are skipped, and the one piece that was only
|
||||
partially written is dropped and re-fetched whole (piece-granular resume,
|
||||
no HTTP range needed). */
|
||||
if (resume) {
|
||||
fp = fopen(dest_path, "r+b");
|
||||
if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; }
|
||||
}
|
||||
if (!fp) { fp = fopen(dest_path, "wb"); have = 0; }
|
||||
if (!fp) { free(manifest.data); return -1; }
|
||||
started = (have <= 0);
|
||||
|
||||
p = pieces;
|
||||
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) {
|
||||
@@ -547,29 +551,51 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
unsigned long long offset = 0;
|
||||
int have_offset, drc;
|
||||
const char *obj_end = strchr(p, '}');
|
||||
progress_state_t progress = {
|
||||
cb,
|
||||
ctx,
|
||||
total,
|
||||
manifest_total ? (long long)manifest_total : 0
|
||||
};
|
||||
|
||||
if (json_string_after(p, "url", url, sizeof(url)))
|
||||
break;
|
||||
json_u64_after(p, "fileSize", &expected);
|
||||
have_offset = (json_u64_after(p, "fileOffset", &offset) == 0);
|
||||
if (verify)
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash));
|
||||
|
||||
/* Piece already fully present from a previous run: skip the download. */
|
||||
if (!started && have_offset && expected &&
|
||||
have >= (long long)(offset + expected)) {
|
||||
total = (long long)(offset + expected);
|
||||
count++;
|
||||
if (cb && cb(ctx, total, manifest_total ? (long long)manifest_total : total))
|
||||
goto done;
|
||||
p = obj_end ? obj_end + 1 : p + 5;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* First piece we must (re)download: drop any partial bytes of it so the
|
||||
appended data lines up exactly, then append from here on. */
|
||||
if (!started) {
|
||||
long long start_at = have_offset ? (long long)offset : 0;
|
||||
fflush(fp);
|
||||
if (ftruncate(fileno(fp), (off_t)start_at) != 0)
|
||||
goto done; /* can't resume cleanly; keep partial */
|
||||
fseek(fp, 0, SEEK_END);
|
||||
total = start_at;
|
||||
started = 1;
|
||||
}
|
||||
|
||||
/* Pieces are concatenated in array order; each one's fileOffset must
|
||||
equal the bytes written so far. A manifest that lists them out of
|
||||
order would otherwise silently produce a corrupt package. */
|
||||
equal the bytes written so far, or the package would be corrupt. */
|
||||
if (have_offset && offset != (unsigned long long)total)
|
||||
goto done;
|
||||
|
||||
/* drc: 0 ok, -1 network/cancel, -2 SHA-256 mismatch (propagated out). */
|
||||
drc = http_download_to_file_progress(url, fp, &got, &progress,
|
||||
hash[0] ? hash : NULL);
|
||||
if (verify)
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash));
|
||||
|
||||
{
|
||||
progress_state_t progress = {
|
||||
cb, ctx, total, manifest_total ? (long long)manifest_total : 0
|
||||
};
|
||||
/* drc: 0 ok, -1 network/cancel, -2 SHA-256 mismatch. */
|
||||
drc = http_download_to_file_progress(url, fp, &got, &progress,
|
||||
hash[0] ? hash : NULL);
|
||||
}
|
||||
if (drc) {
|
||||
if (drc == -2) rc = -2;
|
||||
goto done;
|
||||
@@ -593,7 +619,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
done:
|
||||
fclose(fp);
|
||||
free(manifest.data);
|
||||
if (rc) unlink(dest_path);
|
||||
/* Keep the partial on failure so it can be resumed; the caller deletes it
|
||||
on cancel or on a corrupt-verify (-2). */
|
||||
return rc;
|
||||
}
|
||||
|
||||
@@ -601,7 +628,7 @@ int
|
||||
patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
|
||||
long long *bytes_out) {
|
||||
return patchdl_http_download_manifest_progress(manifest_url, dest_path,
|
||||
bytes_out, NULL, NULL, 0);
|
||||
bytes_out, NULL, NULL, 0, 0);
|
||||
}
|
||||
|
||||
void
|
||||
@@ -696,8 +723,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
const char *dest_path,
|
||||
long long *bytes_out,
|
||||
patchdl_download_progress_cb cb,
|
||||
void *ctx, int verify) {
|
||||
(void)cb; (void)ctx; (void)verify;
|
||||
void *ctx, int verify, int resume) {
|
||||
(void)cb; (void)ctx; (void)verify; (void)resume;
|
||||
return patchdl_http_download_manifest(manifest_url, dest_path, bytes_out);
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -29,14 +29,17 @@ int patchdl_http_download_progress(const char *url, const char *dest_path,
|
||||
|
||||
/* Download a Sony JSON package manifest by concatenating every entry in
|
||||
"pieces" into one installable PKG. When `verify` is non-zero each piece is
|
||||
checked against its manifest SHA-256 (a mismatch returns -2). */
|
||||
checked against its manifest SHA-256 (a mismatch returns -2). When `resume`
|
||||
is non-zero an existing partial at dest_path is kept: fully-downloaded pieces
|
||||
are skipped and only the remainder is fetched (survives a reboot). On any
|
||||
failure the partial is left in place for a later resume. */
|
||||
int patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
|
||||
long long *bytes_out);
|
||||
int patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
const char *dest_path,
|
||||
long long *bytes_out,
|
||||
patchdl_download_progress_cb cb,
|
||||
void *ctx, int verify);
|
||||
void *ctx, int verify, int resume);
|
||||
|
||||
/* Diagnostic: run the GET pipeline for `url` and write a JSON report
|
||||
(dns result/ip, curl code, http status, bytes) into `out_json`. */
|
||||
|
||||
@@ -28,6 +28,8 @@ typedef struct {
|
||||
char patch_storage_title_id[16]; /* title id embedded in delta_url */
|
||||
int verxml_done;
|
||||
int enabled; /* user policy, persisted in config.json */
|
||||
int resumable; /* a partial download is on disk */
|
||||
long long partial_bytes; /* size of that partial, for the UI */
|
||||
} patchdl_title_t;
|
||||
|
||||
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
|
||||
|
||||
+145
-11
@@ -589,6 +589,8 @@ build_titles_json(void) {
|
||||
(!t->patch_title_id[0] ||
|
||||
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
|
||||
jbuf_appendf(&j, ",\"enabled\":%s", t->enabled ? "true" : "false");
|
||||
jbuf_appendf(&j, ",\"resumable\":%s", t->resumable ? "true" : "false");
|
||||
jbuf_appendf(&j, ",\"partial_bytes\":%lld", t->partial_bytes);
|
||||
jbuf_append(&j, ",\"mode\":");
|
||||
jbuf_append_str(&j, title_mode_str(t->source_type));
|
||||
jbuf_append(&j, ",\"queued\":false");
|
||||
@@ -828,6 +830,8 @@ remove_title_dir(const char *title_id) {
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
static void set_title_resumable(const char *title_id, int resumable, long long bytes);
|
||||
|
||||
/* Cancel an in-progress download for this title (the worker aborts and removes
|
||||
the partial), or delete an already-downloaded package if nothing is running. */
|
||||
static enum MHD_Result
|
||||
@@ -842,8 +846,10 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) {
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
|
||||
if (!was_active)
|
||||
if (!was_active) {
|
||||
remove_title_dir(title_id);
|
||||
set_title_resumable(title_id, 0, 0);
|
||||
}
|
||||
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"ok\":true,\"cancelled\":%s,\"deleted\":true}",
|
||||
@@ -851,13 +857,78 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) {
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
|
||||
}
|
||||
|
||||
/* ---------- resume sidecar (/data/patchdl/<title>/state.json) ----------- */
|
||||
|
||||
static long long
|
||||
file_size(const char *path) {
|
||||
struct stat st;
|
||||
if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) return (long long)st.st_size;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void
|
||||
title_state_path(const char *title_id, char *out, size_t sz) {
|
||||
snprintf(out, sz, "%s/%s/state.json", PATCHDL_DL_DIR, title_id);
|
||||
}
|
||||
|
||||
/* Record which manifest a partial download belongs to, so a resume only
|
||||
continues a partial that matches the current patch. Sony CDN URLs contain no
|
||||
characters that need JSON escaping, so the value is embedded verbatim. */
|
||||
static void
|
||||
write_dl_state(const char *title_id, const char *manifest_url) {
|
||||
char path[320];
|
||||
FILE *f;
|
||||
title_state_path(title_id, path, sizeof(path));
|
||||
f = fopen(path, "w");
|
||||
if (!f) return;
|
||||
fprintf(f, "{\"manifest_url\":\"%s\"}\n", manifest_url);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static int
|
||||
read_dl_state_url(const char *title_id, char *out, size_t sz) {
|
||||
char path[320], buf[1280];
|
||||
FILE *f;
|
||||
size_t n;
|
||||
out[0] = '\0';
|
||||
title_state_path(title_id, path, sizeof(path));
|
||||
f = fopen(path, "r");
|
||||
if (!f) return -1;
|
||||
n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
json_get_str(buf, "manifest_url", out, sz);
|
||||
return out[0] ? 0 : -1;
|
||||
}
|
||||
|
||||
static void
|
||||
remove_dl_state(const char *title_id) {
|
||||
char path[320];
|
||||
title_state_path(title_id, path, sizeof(path));
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
/* Keep the resumable flag (set at startup) in sync after a download finishes or
|
||||
its partial is deleted, so /api/titles stops reporting a stale partial. */
|
||||
static void
|
||||
set_title_resumable(const char *title_id, int resumable, long long bytes) {
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
for (size_t i = 0; i < g_title_count; i++)
|
||||
if (!strcmp(g_titles[i].title_id, title_id)) {
|
||||
g_titles[i].resumable = resumable;
|
||||
g_titles[i].partial_bytes = bytes;
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
patchdl_source_t src, const char *patch_url,
|
||||
const char *name, const char *version, int enabled) {
|
||||
char dir[256], dest[320], resp[640];
|
||||
long long bytes = 0;
|
||||
int verify = 0, dlrc;
|
||||
int verify = 0, dlrc, is_manifest, resume = 0;
|
||||
|
||||
if (!enabled)
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
@@ -877,6 +948,22 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
mkdir(dir, 0777);
|
||||
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
|
||||
|
||||
/* Resume: keep an existing partial only if it belongs to THIS manifest
|
||||
(recorded in the sidecar); a partial from a different/older patch is
|
||||
dropped. The partial survives a reboot because a killed process runs no
|
||||
cleanup. Only manifest downloads resume. */
|
||||
is_manifest = url_is_manifest(patch_url);
|
||||
if (is_manifest && file_size(dest) > 0) {
|
||||
char prev_url[1024] = {0};
|
||||
if (read_dl_state_url(title_id, prev_url, sizeof(prev_url)) == 0 &&
|
||||
!strcmp(prev_url, patch_url))
|
||||
resume = 1;
|
||||
else
|
||||
unlink(dest); /* stale partial from a different patch */
|
||||
}
|
||||
if (is_manifest)
|
||||
write_dl_state(title_id, patch_url);
|
||||
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
if (g_dl.active) {
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
@@ -892,9 +979,9 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
verify = g_cfg.verify_downloads;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
|
||||
dlrc = url_is_manifest(patch_url)
|
||||
dlrc = is_manifest
|
||||
? patchdl_http_download_manifest_progress(patch_url, dest, &bytes,
|
||||
download_progress_cb, NULL, verify)
|
||||
download_progress_cb, NULL, verify, resume)
|
||||
: patchdl_http_download_progress(patch_url, dest, &bytes,
|
||||
download_progress_cb, NULL);
|
||||
if (dlrc) {
|
||||
@@ -905,16 +992,27 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
g_dl.cancel = 0;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
|
||||
/* The download function already unlinked the partial file on failure;
|
||||
drop the now-empty title dir too. */
|
||||
unlink(dest);
|
||||
rmdir(dir);
|
||||
|
||||
if (was_cancel)
|
||||
if (was_cancel) {
|
||||
/* user cancelled: drop the partial + its resume sidecar */
|
||||
unlink(dest);
|
||||
remove_dl_state(title_id);
|
||||
rmdir(dir);
|
||||
set_title_resumable(title_id, 0, 0);
|
||||
return queue_json(conn, MHD_HTTP_OK,
|
||||
"{\"ok\":false,\"cancelled\":true,"
|
||||
"\"reason\":\"download_cancelled\"}");
|
||||
/* -2 = a piece failed its SHA-256 (only possible when verify is on). */
|
||||
}
|
||||
if (dlrc == -2) {
|
||||
/* corrupt data (failed SHA-256): don't keep it for resume */
|
||||
unlink(dest);
|
||||
remove_dl_state(title_id);
|
||||
rmdir(dir);
|
||||
set_title_resumable(title_id, 0, 0);
|
||||
} else {
|
||||
/* network failure: the partial + sidecar are kept, and the title is
|
||||
now resumable (also survives a reboot). */
|
||||
set_title_resumable(title_id, 1, file_size(dest));
|
||||
}
|
||||
snprintf(resp, sizeof(resp), "{\"ok\":false,\"reason\":\"%s\"}",
|
||||
dlrc == -2 ? "piece_verify_failed" : "download_failed");
|
||||
return queue_json_owned(conn, MHD_HTTP_BAD_GATEWAY, strdup(resp));
|
||||
@@ -926,6 +1024,9 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
g_dl.active = 0;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
|
||||
remove_dl_state(title_id); /* complete: drop sidecar, keep the pkg */
|
||||
set_title_resumable(title_id, 0, 0); /* no longer a partial */
|
||||
|
||||
/* shadowmount: download allowed, install is not (per source policy). */
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"ok\":true,\"downloaded\":true,\"bytes\":%lld,\"path\":\"%s\","
|
||||
@@ -1204,6 +1305,35 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
return queue_asset(conn, url);
|
||||
}
|
||||
|
||||
/* A title is resumable when its download dir holds both a resume sidecar and a
|
||||
partial .pkg. Records the partial size for the UI. Single-threaded startup. */
|
||||
static void
|
||||
detect_resumable_partials(void) {
|
||||
char dir[288], state[320], pkg[576];
|
||||
DIR *d;
|
||||
struct dirent *e;
|
||||
|
||||
for (size_t i = 0; i < g_title_count; i++) {
|
||||
patchdl_title_t *t = &g_titles[i];
|
||||
title_state_path(t->title_id, state, sizeof(state));
|
||||
if (file_size(state) < 0) continue; /* no sidecar -> not resumable */
|
||||
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, t->title_id);
|
||||
d = opendir(dir);
|
||||
if (!d) continue;
|
||||
while ((e = readdir(d))) {
|
||||
size_t nl = strlen(e->d_name);
|
||||
if (nl > 4 && !strcmp(e->d_name + nl - 4, ".pkg")) {
|
||||
long long sz;
|
||||
snprintf(pkg, sizeof(pkg), "%s/%s", dir, e->d_name);
|
||||
sz = file_size(pkg);
|
||||
if (sz > 0) { t->resumable = 1; t->partial_bytes = sz; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- lifecycle -------------------------------------------------- */
|
||||
|
||||
int
|
||||
@@ -1226,6 +1356,10 @@ patchdl_websrv_start(unsigned short port) {
|
||||
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
|
||||
load_config();
|
||||
|
||||
/* Flag titles that have a partial download on disk so the UI can offer
|
||||
Resume after a reboot. */
|
||||
detect_resumable_partials();
|
||||
|
||||
/* Build the diagnostic dump now, while single-threaded — the root-vnode
|
||||
swap it performs is unsafe once MHD worker threads are running. */
|
||||
g_debug_json = patchdl_scan_debug_json();
|
||||
|
||||
+26
-4
@@ -312,6 +312,7 @@ function createGameCard(game) {
|
||||
</div>
|
||||
<div class="pills">
|
||||
${game.downloading ? `<span class="pill live">Downloading</span>` : ""}
|
||||
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
|
||||
${statusPill(game)}
|
||||
${sourcePill(game)}
|
||||
</div>
|
||||
@@ -334,12 +335,12 @@ function createGameCard(game) {
|
||||
else btn.addEventListener("click", () => runTitleAction(game.title_id, act.action));
|
||||
actions.appendChild(btn);
|
||||
}
|
||||
// Delete a finished (not-yet-installed) download.
|
||||
if (game.downloaded && !game.installing && !game.downloading) {
|
||||
// Delete a finished or paused (partial) download.
|
||||
if ((game.downloaded || game.resumable) && !game.installing && !game.downloading) {
|
||||
const del = document.createElement("button");
|
||||
del.className = "row-button is-ghost";
|
||||
del.textContent = "Delete";
|
||||
del.title = "Delete the downloaded package";
|
||||
del.title = game.resumable ? "Delete the partial download" : "Delete the downloaded package";
|
||||
del.addEventListener("click", () => cancelDownload(game.title_id));
|
||||
actions.appendChild(del);
|
||||
}
|
||||
@@ -357,6 +358,17 @@ function createGameCard(game) {
|
||||
<div class="progress-meta">${progressMetaHtml(d)}</div>
|
||||
`;
|
||||
card.appendChild(prog);
|
||||
} else if (game.resumable && game.partial_bytes > 0) {
|
||||
// ---- paused partial (survived a reboot) ----
|
||||
const note = document.createElement("div");
|
||||
note.className = "card-progress";
|
||||
note.innerHTML = `
|
||||
<div class="progress-meta">
|
||||
<span>Paused — <b>${formatBytes(game.partial_bytes)}</b> downloaded</span>
|
||||
<span>Resume to continue</span>
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(note);
|
||||
}
|
||||
|
||||
return card;
|
||||
@@ -391,6 +403,8 @@ function tileButton(game) {
|
||||
if (!isInstallAllowed(game)) return null;
|
||||
if (game.downloaded && game.status === "available")
|
||||
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
|
||||
if (game.resumable)
|
||||
return { label: "Resume", action: "download", variant: "update", hint: "Resume the interrupted download where it stopped." };
|
||||
if (game.status !== "available") return null;
|
||||
return state.config.install_after_download
|
||||
? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." }
|
||||
@@ -603,6 +617,8 @@ async function doDownload(game) {
|
||||
}
|
||||
|
||||
game.downloaded = true;
|
||||
game.resumable = false;
|
||||
game.partial_bytes = 0;
|
||||
const sz = r && r.bytes ? formatBytes(r.bytes) : "?";
|
||||
state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${sz}, internal)`);
|
||||
showToast(`${game.name}: downloaded ${sz}.`);
|
||||
@@ -640,7 +656,13 @@ async function cancelDownload(titleId) {
|
||||
} catch (error) {
|
||||
showToast(`${game ? game.name : titleId}: ${reasonText(error)}`);
|
||||
}
|
||||
if (game) { game.downloading = false; game._localDownloading = false; game.downloaded = false; }
|
||||
if (game) {
|
||||
game.downloading = false;
|
||||
game._localDownloading = false;
|
||||
game.downloaded = false;
|
||||
game.resumable = false;
|
||||
game.partial_bytes = 0;
|
||||
}
|
||||
state.downloads = state.downloads.filter((i) => i.title_id !== titleId);
|
||||
state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${titleId}`);
|
||||
renderGames(); renderLogs();
|
||||
|
||||
Reference in new issue
Block a user