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

+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\"}");