Add pause/resume, within-part byte-range resume; bump to 0.0.3

Split the single morphing button into a green/amber play-pause (Update →
Pause → Resume) and a red stop (Cancel). Pause aborts the download but
keeps the partial (resumable); Cancel aborts and deletes. Backend gets a
separate pause flag distinct from cancel.

Resume now continues WITHIN a part: the partially-written piece is fetched
from its last byte via an HTTP byte range (with a safe fall back to
re-fetching the whole piece if the CDN ignores the range), instead of
re-downloading the whole part. A title is resumable as soon as any bytes
are on disk.

Version bumped to 0.0.3 (no release tagged).
This commit is contained in:
Knutwurst committed 2026-06-24 09:47:44 +02:00
1 parent 66e4912485
commit 01eaef79f2
7 files changed
+164 -58

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ http://<console-ip>:12880/
## Status
0.0.2, early. Title scan, source classification, version resolution,
0.0.3, early. Title scan, source classification, version resolution,
firmware-compatibility filtering, target/storage-id handling, and the local
AppInstUtil HTTP stream have been verified on firmware 11.60. PatchDL now
downloads PS5 update manifests as merged piece packages under `/data/patchdl`;
+69 -22
View File
@@ -344,14 +344,20 @@ curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
}
/* Returns 0 on success, -1 on download/network failure, -2 when an expected
SHA-256 was given and the downloaded bytes did not match it. */
SHA-256 was given and the downloaded bytes did not match it, -3 when a byte
range was requested (range_start>0) but the server ignored it (no HTTP 206).
When range_start>0 the body is appended at the file's current position, so
the caller must have it positioned at range_start and must not verify. */
static int
http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
progress_state_t *progress,
const char *expected_sha256_hex) {
const char *expected_sha256_hex,
long long range_start) {
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
char range_hdr[48];
long http_code = 0;
struct curl_slist *rl = NULL;
struct curl_blob ca_blob;
curl_off_t dl = 0;
@@ -400,6 +406,10 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
if (range_start > 0) {
snprintf(range_hdr, sizeof(range_hdr), "%lld-", range_start);
curl_easy_setopt(curl, CURLOPT_RANGE, range_hdr);
}
if (progress && progress->cb) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_progress_cb);
@@ -407,6 +417,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
}
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
@@ -415,6 +426,12 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
if (sink.md) EVP_MD_CTX_free(sink.md);
return -1;
}
/* Asked for a byte range but the server sent the whole file (no 206): the
caller must drop the piece and re-fetch it whole. */
if (range_start > 0 && http_code != 206) {
if (sink.md) EVP_MD_CTX_free(sink.md);
return -3;
}
if (sink.md) {
unsigned char dig[EVP_MAX_MD_SIZE];
@@ -440,7 +457,7 @@ patchdl_http_download_progress(const char *url, const char *dest_path,
int rc;
if (!fp) return -1;
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL);
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL, 0);
fclose(fp);
if (rc) {
@@ -531,9 +548,9 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
json_u64_after(manifest.data, "originalFileSize", &manifest_total);
/* 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). */
Fully-downloaded pieces are skipped; the one piece that was only partially
written continues mid-piece via an HTTP byte range (with a fall back to
re-fetching it whole if the CDN ignores the range). */
if (resume) {
fp = fopen(dest_path, "r+b");
if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; }
@@ -546,10 +563,11 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) {
char url[768];
char hash[80] = {0};
long long got = 0;
long long got = 0, range_start = 0;
unsigned long long expected = 0;
unsigned long long offset = 0;
int have_offset, drc;
const char *want_hash;
const char *obj_end = strchr(p, '}');
if (json_string_after(p, "url", url, sizeof(url)))
@@ -568,39 +586,68 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
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. */
/* First piece to (re)download while resuming. If part of it is already
on disk, resume WITHIN it with a byte range; otherwise drop any stray
bytes and fetch it whole. After this, every piece is fetched whole. */
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;
if (have_offset && expected && have > (long long)offset &&
have < (long long)(offset + expected)) {
range_start = have - (long long)offset; /* this piece's bytes on disk */
fseek(fp, 0, SEEK_END); /* append at `have` */
total = have;
} else {
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, or the package would be corrupt. */
if (have_offset && offset != (unsigned long long)total)
/* Whole pieces are concatenated in array order; a ranged (partial) piece
starts mid-piece, so the contiguity guard applies only to whole ones. */
if (have_offset && range_start == 0 && offset != (unsigned long long)total)
goto done;
if (verify)
/* A ranged piece can't be hashed (only its tail is fetched). */
want_hash = NULL;
if (range_start == 0 && verify) {
json_string_after(p, "hashValue", hash, sizeof(hash));
want_hash = hash[0] ? hash : NULL;
}
{
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: 0 ok, -1 network/cancel, -2 SHA-256, -3 range ignored. */
drc = http_download_to_file_progress(url, fp, &got, &progress,
hash[0] ? hash : NULL);
want_hash, range_start);
if (drc == -3) {
/* Server ignored the range: drop the piece and fetch it whole. */
fflush(fp);
if (ftruncate(fileno(fp), (off_t)offset) != 0)
goto done;
fseek(fp, 0, SEEK_END);
total = (long long)offset;
range_start = 0;
if (verify) {
json_string_after(p, "hashValue", hash, sizeof(hash));
want_hash = hash[0] ? hash : NULL;
}
progress.base = total;
drc = http_download_to_file_progress(url, fp, &got, &progress,
want_hash, 0);
}
}
if (drc) {
if (drc == -2) rc = -2;
goto done;
}
if (expected && (unsigned long long)got != expected)
/* range_start + got = this piece's bytes now on disk. */
if (expected && (unsigned long long)(range_start + got) != expected)
goto done;
total += got;
+1 -1
View File
@@ -1,3 +1,3 @@
#pragma once
#define PATCHDL_VERSION "0.0.2"
#define PATCHDL_VERSION "0.0.3"
+41 -7
View File
@@ -43,7 +43,8 @@ static struct {
static struct {
int active;
int cancel; /* set by a cancel request; the worker aborts */
int cancel; /* set by a cancel request; worker aborts + deletes */
int pause; /* set by a pause request; worker aborts, keeps partial */
char title_id[32];
char name[128];
char version[16];
@@ -788,19 +789,19 @@ url_is_manifest(const char *url) {
return n > 5 && !strcmp(url + n - 5, ".json");
}
/* Returns non-zero to abort the download when a cancel has been requested. */
/* Returns non-zero to abort the download when a cancel OR pause is requested. */
static int
download_progress_cb(void *ctx, long long downloaded, long long total) {
int cancel;
int abort_now;
(void)ctx;
pthread_mutex_lock(&g_mutex);
if (g_dl.active) {
g_dl.downloaded = downloaded;
g_dl.total = total;
}
cancel = g_dl.cancel;
abort_now = g_dl.cancel || g_dl.pause;
pthread_mutex_unlock(&g_mutex);
return cancel;
return abort_now;
}
/* Delete a title's internal download directory and its contents (a partial or
@@ -857,6 +858,23 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) {
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
/* Pause an in-progress download: the worker aborts but the partial is kept on
disk (and marked resumable), so it can be continued later. */
static enum MHD_Result
do_pause(struct MHD_Connection *conn, const char *title_id) {
int active = 0;
pthread_mutex_lock(&g_mutex);
if (g_dl.active && !strcmp(g_dl.title_id, title_id)) {
g_dl.pause = 1;
active = 1;
}
pthread_mutex_unlock(&g_mutex);
if (!active)
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"not_downloading\"}");
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"paused\":true}");
}
/* ---------- resume sidecar (/data/patchdl/<title>/state.json) ----------- */
static long long
@@ -985,15 +1003,18 @@ do_download(struct MHD_Connection *conn, const char *title_id,
: patchdl_http_download_progress(patch_url, dest, &bytes,
download_progress_cb, NULL);
if (dlrc) {
int was_cancel;
int was_cancel, was_pause;
long long have;
pthread_mutex_lock(&g_mutex);
was_cancel = g_dl.cancel;
was_pause = g_dl.pause;
g_dl.active = 0;
g_dl.cancel = 0;
g_dl.pause = 0;
pthread_mutex_unlock(&g_mutex);
if (was_cancel) {
/* user cancelled: drop the partial + its resume sidecar */
/* user cancelled (stop): drop the partial + its resume sidecar */
unlink(dest);
remove_dl_state(title_id);
rmdir(dir);
@@ -1002,6 +1023,16 @@ do_download(struct MHD_Connection *conn, const char *title_id,
"{\"ok\":false,\"cancelled\":true,"
"\"reason\":\"download_cancelled\"}");
}
if (was_pause) {
/* user paused: keep the partial + sidecar; it is now resumable. */
have = file_size(dest);
if (have < 0) have = 0;
set_title_resumable(title_id, have > 0, have);
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"paused\":true,\"reason\":\"download_paused\","
"\"bytes\":%lld}", have);
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
if (dlrc == -2) {
/* corrupt data (failed SHA-256): don't keep it for resume */
unlink(dest);
@@ -1152,6 +1183,9 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
if (!strcmp(action, "cancel"))
return do_cancel(conn, title_id);
if (!strcmp(action, "pause"))
return do_pause(conn, title_id);
if (!strcmp(action, "check"))
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
+44 -22
View File
@@ -325,8 +325,8 @@ function createGameCard(game) {
const actions = document.createElement("div");
actions.className = "card-actions";
const act = tileButton(game);
if (act) {
[primaryButton(game), stopButton(game)].forEach((act) => {
if (!act) return;
const btn = document.createElement("button");
btn.className = `row-button is-${act.variant}`;
btn.textContent = act.label;
@@ -334,16 +334,7 @@ function createGameCard(game) {
if (act.disabled) btn.disabled = true;
else btn.addEventListener("click", () => runTitleAction(game.title_id, act.action));
actions.appendChild(btn);
}
// 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 = game.resumable ? "Delete the partial download" : "Delete the downloaded package";
del.addEventListener("click", () => cancelDownload(game.title_id));
actions.appendChild(del);
}
});
row.append(lead, body, actions);
card.appendChild(row);
@@ -394,23 +385,32 @@ function buildToggle(game) {
return toggle;
}
// The single morphing action button: blue Update/Download/Install, or amber
// Cancel while a download runs. Fixed width (CSS) so the label never reflows.
function tileButton(game) {
// Primary play/pause button (green to go, amber while downloading). Fixed width
// (CSS) so the label never reflows. Returns null when there is nothing to do.
function primaryButton(game) {
if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true };
if (game.downloading) return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop the download and delete the partial file" };
if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." };
if (game.patch_title_match === false) return null;
if (!isInstallAllowed(game)) return null;
if (game.resumable)
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
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." }
: { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." };
}
// Red stop button: present whenever there is a download to stop or discard.
// Cancel stops AND deletes (unlike Pause, which keeps the partial).
function stopButton(game) {
if (game.installing) return null;
if (game.downloading || game.resumable || game.downloaded)
return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop and delete the download." };
return null;
}
function statusPill(game) {
if (game.installing) return `<span class="pill warn">Installing…</span>`;
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
@@ -606,12 +606,20 @@ async function doDownload(game) {
game._localDownloading = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
// Cancel / soft failure returns HTTP 200 with ok:false (not thrown).
// Cancel / pause / soft failure returns HTTP 200 with ok:false (not thrown).
if (!r || r.ok === false) {
game.downloaded = false;
const what = r && r.cancelled ? "cancelled" : "failed";
state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`);
showToast(`${game.name}: download ${what}.`);
if (r && r.paused) {
// Paused: keep the partial, mark resumable so the tile shows Resume.
game.resumable = true;
game.partial_bytes = r.bytes || game.partial_bytes || 0;
state.logs.push(`[${timeNow()}] Download paused: ${game.title_id} (${formatBytes(game.partial_bytes)})`);
showToast(`${game.name}: paused at ${formatBytes(game.partial_bytes)}.`);
} else {
const what = r && r.cancelled ? "cancelled" : "failed";
state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`);
showToast(`${game.name}: download ${what}.`);
}
renderGames(); renderLogs(); stopDownloadPolling();
return false;
}
@@ -674,10 +682,22 @@ async function runTitleAction(titleId, action) {
if (!game) return;
if (action === "download") await doDownload(game);
else if (action === "install") await doInstall(game);
else if (action === "pause") await doPause(game);
else if (action === "cancel") await cancelDownload(titleId);
else if (action === "update") { if (await doDownload(game)) await doInstall(game); }
}
// Pause only sends the signal; the in-flight doDownload() request returns its
// "paused" result and updates the card (resumable + partial bytes).
async function doPause(game) {
try {
await postJson(API.action(game.title_id, "pause"), {});
showToast(`${game.name}: pausing…`);
} catch (error) {
showToast(`${game.name}: ${reasonText(error)}`);
}
}
function updateGame(titleId, patch) {
const game = state.titles.find((i) => i.title_id === titleId);
if (!game) return;
@@ -733,6 +753,8 @@ const REASON_TEXT = {
download_in_progress: "Another download is already running.",
piece_verify_failed: "A downloaded piece failed its SHA-256 check.",
title_disabled: "This title is disabled.",
download_paused: "Download paused.",
not_downloading: "Nothing is downloading for this title.",
};
function reasonText(error) {
const r = error && error.body && error.body.reason;
+1 -1
View File
@@ -39,7 +39,7 @@
<div class="brand-mark">PD</div>
<div>
<strong>PatchDL</strong>
<span>by Knutwurst · v0.0.2</span>
<span>by Knutwurst · v0.0.3</span>
</div>
</div>
+7 -4
View File
@@ -331,10 +331,13 @@ h2 { font-size: 18px; line-height: 1.2; }
width: 100%; /* fills the fixed-width actions column -> never reflows */
padding: 0 12px;
}
.row-button.is-update { border-color: var(--blue); color: #04111f; background: var(--blue); }
.row-button.is-update:hover { background: var(--blue-dark); }
.row-button.is-cancel { border-color: var(--amber); color: #1c1402; background: var(--amber); }
.row-button.is-cancel:hover { background: var(--amber-dark); }
/* go = green (Update / Resume / Install), pause = amber, cancel/stop = red */
.row-button.is-update { border-color: var(--green); color: #04150c; background: var(--green); }
.row-button.is-update:hover { background: var(--green-dark); }
.row-button.is-pause { border-color: var(--amber); color: #1c1402; background: var(--amber); }
.row-button.is-pause:hover { background: var(--amber-dark); }
.row-button.is-cancel { border-color: var(--red); color: #1a0606; background: var(--red); }
.row-button.is-cancel:hover { background: #ff8a8a; }
.row-button.is-ghost { background: transparent; color: var(--muted); }
.row-button.is-ghost:hover { color: var(--ink); border-color: var(--muted); }
.row-button:disabled { opacity: 0.6; cursor: default; }