sfdown 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +11 -2
- data/lib/sfdown/version.rb +2 -2
- data/lib/sfdown.rb +67 -19
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ac26367a5dfb5104d0765390e182530c231d080825c2af68ac3cd55746eefb7
|
|
4
|
+
data.tar.gz: 1f6198ec690293e27ee67165c1ae6e131a08564398d529ae5107c65cfce66eae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9cc50d15933e4d0c3bf55447c8a10ab9caf651e0fb4ef52540cbfcfda5fe8d02b39f102ff43a500de06471d5865e22a0a82508c706f45ba93dbbb2230f80057
|
|
7
|
+
data.tar.gz: a3ce4092601da155a1e9311aa1d1bc88ef2b7a05435381f07d07499e8b470bec1c629467b881c305dfa091bb8ce3a4ce8c4bef52405ce551056224b96347ae0d
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.4.1] - 2026-07-25
|
|
6
|
+
|
|
7
|
+
Resumable downloads.
|
|
8
|
+
|
|
9
|
+
- Downloader now skips files already on disk whose content verifies against their MD5/SHA1 hash, so an interrupted download can be resumed by rerunning the same command (works with `-i` too).
|
|
10
|
+
- Missing, corrupt, or checksum-less files are re-downloaded.
|
|
11
|
+
- Final summary reports how many files were resumed and how many bytes that skipped.
|
|
12
|
+
|
|
5
13
|
## [0.4.0] - 2026-07-24
|
|
6
14
|
|
|
7
15
|
Added file deduplication.
|
data/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# sfdown
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/sfdown)
|
|
4
|
+
[](https://rubygems.org/gems/sfdown)
|
|
5
|
+
|
|
3
6
|
SourceForge project downloader. Will clone the project's directory tree and fetch all files.
|
|
4
7
|
|
|
5
|
-
Supports [concurrency](#concurrency-and-throttling), [integrity verification](#integrity-verification)
|
|
8
|
+
Supports [concurrency](#concurrency-and-throttling), [integrity verification](#integrity-verification), [file deduplication](#file-deduplication) and [resuming](#resuming-an-interrupted-download) interrupted downloads.
|
|
6
9
|
|
|
7
10
|
## Installation
|
|
8
11
|
|
|
@@ -58,4 +61,10 @@ Multiple workers can be used in parallel for both stages via the `-c` flag.
|
|
|
58
61
|
|
|
59
62
|
To prevent rate-limits, an optional sleep can be added between fetches via the `-s` flag.
|
|
60
63
|
|
|
61
|
-
**Note**: `-s` is applied per worker, so with `-c` the effective request rate scales roughly linearly with the number of workers.
|
|
64
|
+
**Note**: `-s` is applied per worker, so with `-c` the effective request rate scales roughly linearly with the number of workers.
|
|
65
|
+
|
|
66
|
+
### Resuming interrupted downloads
|
|
67
|
+
|
|
68
|
+
The download process (stage 2) can be resumed by simply running the same command again, whether the process had been bootstrapped with `-i` or not.
|
|
69
|
+
|
|
70
|
+
Before downloading anything, sfdown checks each file's local path and verifies any existing copy against its published **MD5** / **SHA1** hash. Files that match are kept and skipped; anything missing, corrupt, or unverifiable (no checksum) is re-downloaded.
|
data/lib/sfdown/version.rb
CHANGED
data/lib/sfdown.rb
CHANGED
|
@@ -420,8 +420,10 @@ module Sfdown
|
|
|
420
420
|
@total_files = 0
|
|
421
421
|
@total_bytes = 0
|
|
422
422
|
@duped_bytes = 0
|
|
423
|
-
@
|
|
424
|
-
@
|
|
423
|
+
@resumed_files = 0
|
|
424
|
+
@resumed_bytes = 0
|
|
425
|
+
@done_files = 0 # completed this session (fetched + cloned)
|
|
426
|
+
@done_bytes = 0 # streamed this session
|
|
425
427
|
@duped_files = 0
|
|
426
428
|
@failures = 0
|
|
427
429
|
@mismatches = 0
|
|
@@ -429,16 +431,22 @@ module Sfdown
|
|
|
429
431
|
@current = ""
|
|
430
432
|
end
|
|
431
433
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
+
# Progress counters credit files already on disk from a previous run, so a
|
|
435
|
+
# resumed download shows e.g. 500/700 MB rather than restarting at 0.
|
|
436
|
+
def files_done = @mutex.synchronize { @resumed_files + @done_files }
|
|
437
|
+
def bytes_done = @mutex.synchronize { @resumed_bytes + @done_bytes }
|
|
438
|
+
def downloaded_bytes = @mutex.synchronize { @done_bytes } # this session only; drives speed
|
|
439
|
+
def resumed_files = @mutex.synchronize { @resumed_files }
|
|
440
|
+
def resumed_bytes = @mutex.synchronize { @resumed_bytes }
|
|
434
441
|
def duped_files = @mutex.synchronize { @duped_files }
|
|
435
442
|
def failures = @mutex.synchronize { @failures }
|
|
436
443
|
def mismatches = @mutex.synchronize { @mismatches }
|
|
437
444
|
def current = @mutex.synchronize { @current }
|
|
438
445
|
|
|
439
|
-
# Create every directory up front, then split the files into those
|
|
440
|
-
# and the duplicates
|
|
441
|
-
#
|
|
446
|
+
# Create every directory up front, then split the files into those already
|
|
447
|
+
# on disk (resume), those to fetch, and the duplicates cloned from them.
|
|
448
|
+
# total_bytes covers the resumed + fetched bytes, so a resumed run's bar
|
|
449
|
+
# starts partway to 100% instead of at zero.
|
|
442
450
|
def prepare(root)
|
|
443
451
|
FileUtils.mkdir_p(@dest)
|
|
444
452
|
files = []
|
|
@@ -448,8 +456,9 @@ module Sfdown
|
|
|
448
456
|
end
|
|
449
457
|
@total_files = files.size
|
|
450
458
|
plan(files)
|
|
451
|
-
@total_bytes = @files.sum(&:size)
|
|
459
|
+
@total_bytes = @resumed_bytes + @files.sum(&:size)
|
|
452
460
|
@duped_bytes = @dupes.sum { |node, _| node.size }
|
|
461
|
+
log_resume if @resumed_files.positive?
|
|
453
462
|
end
|
|
454
463
|
|
|
455
464
|
# The work list is fixed here (unlike stage 1), so the queue is filled and
|
|
@@ -482,14 +491,19 @@ module Sfdown
|
|
|
482
491
|
Pool.run(queue, [@concurrent, files.size].min) { |node| download_one(node) }
|
|
483
492
|
end
|
|
484
493
|
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
494
|
+
# Classify every file. A copy already on disk that verifies against its
|
|
495
|
+
# checksum is kept as-is (resume). Otherwise the first node carrying a given
|
|
496
|
+
# checksum is downloaded and any later node with the same one is a duplicate
|
|
497
|
+
# of it. Files SF gives no checksum for can't be verified or deduped, so they
|
|
498
|
+
# are always fetched (their on-disk state can't be trusted).
|
|
488
499
|
def plan(files)
|
|
489
500
|
sources = {}
|
|
490
501
|
files.each do |node|
|
|
491
502
|
key = dedupe_key(node)
|
|
492
|
-
if (
|
|
503
|
+
if present?(node)
|
|
504
|
+
resume(node)
|
|
505
|
+
sources[key] ||= node if key # a verified local copy can source dupes
|
|
506
|
+
elsif key && (source = sources[key])
|
|
493
507
|
@dupes << [node, source]
|
|
494
508
|
else
|
|
495
509
|
sources[key] = node if key
|
|
@@ -503,19 +517,44 @@ module Sfdown
|
|
|
503
517
|
algo && "#{algo}:#{hash.downcase}"
|
|
504
518
|
end
|
|
505
519
|
|
|
520
|
+
# Count an already-present file as done and register it so dupes can clone it.
|
|
521
|
+
def resume(node)
|
|
522
|
+
@resumed_files += 1
|
|
523
|
+
@resumed_bytes += node.size
|
|
524
|
+
@ok << node.path
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# True if the local file exists and its content matches the node's checksum.
|
|
528
|
+
# Without a checksum we can't confirm integrity, so we never trust the copy.
|
|
529
|
+
def present?(node)
|
|
530
|
+
algo, expected = expected_hash(node)
|
|
531
|
+
return false unless algo
|
|
532
|
+
|
|
533
|
+
path = local_path(node)
|
|
534
|
+
File.file?(path) && digest_file(path, algo).casecmp?(expected)
|
|
535
|
+
rescue StandardError
|
|
536
|
+
false
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def digest_file(path, algo)
|
|
540
|
+
digest = Digest.const_get(algo).new
|
|
541
|
+
File.open(path, "rb") { |f| digest.update(f.read(1 << 16)) while !f.eof? }
|
|
542
|
+
digest.hexdigest
|
|
543
|
+
end
|
|
544
|
+
|
|
506
545
|
def download_one(node)
|
|
507
546
|
@mutex.synchronize { @current = node.path }
|
|
508
547
|
dest = local_path(node)
|
|
509
548
|
algo, expected = expected_hash(node)
|
|
510
549
|
digest = algo && Digest.const_get(algo).new
|
|
511
550
|
@http.download(Sf.file_url(@project, node.path), dest) do |chunk|
|
|
512
|
-
@mutex.synchronize { @
|
|
551
|
+
@mutex.synchronize { @done_bytes += chunk.bytesize }
|
|
513
552
|
digest&.update(chunk)
|
|
514
553
|
end
|
|
515
554
|
File.utime(node.timestamp, node.timestamp, dest) if node.timestamp
|
|
516
555
|
verify(node, digest.hexdigest, expected) if digest
|
|
517
556
|
@mutex.synchronize do
|
|
518
|
-
@
|
|
557
|
+
@done_files += 1
|
|
519
558
|
@ok << node.path
|
|
520
559
|
end
|
|
521
560
|
rescue StandardError => e
|
|
@@ -541,7 +580,7 @@ module Sfdown
|
|
|
541
580
|
# original's timestamps; only a real copy can carry its own.
|
|
542
581
|
File.utime(node.timestamp, node.timestamp, dest) if node.timestamp && !linked
|
|
543
582
|
@mutex.synchronize do
|
|
544
|
-
@
|
|
583
|
+
@done_files += 1
|
|
545
584
|
@duped_files += 1
|
|
546
585
|
@ok << node.path
|
|
547
586
|
end
|
|
@@ -585,6 +624,10 @@ module Sfdown
|
|
|
585
624
|
@log.call("WARN: checksum mismatch for #{node.path} (expected #{expected}, got #{actual})")
|
|
586
625
|
end
|
|
587
626
|
|
|
627
|
+
def log_resume
|
|
628
|
+
@log.call("Resuming: #{@resumed_files} verified file(s) already present (#{Fmt.size(@resumed_bytes)}), skipping them")
|
|
629
|
+
end
|
|
630
|
+
|
|
588
631
|
def each_node(node, &blk)
|
|
589
632
|
blk.call(node)
|
|
590
633
|
node.children.each { |c| each_node(c, &blk) }
|
|
@@ -782,7 +825,9 @@ module Sfdown
|
|
|
782
825
|
def render_stage2(bar, dl, global_start, stage2_start)
|
|
783
826
|
now = Time.now
|
|
784
827
|
s2 = now - stage2_start
|
|
785
|
-
|
|
828
|
+
# Speed reflects only bytes moved this session; resumed bytes weren't
|
|
829
|
+
# transferred now, so counting them would inflate speed and skew the ETA.
|
|
830
|
+
speed = s2.positive? ? dl.downloaded_bytes / s2 : 0
|
|
786
831
|
remaining = [dl.total_bytes - dl.bytes_done, 0].max
|
|
787
832
|
eta = speed.positive? ? remaining / speed : 0
|
|
788
833
|
bar.update("Fetching #{dl.current}", stage2_line(dl, now - global_start, speed, (now - global_start) + eta))
|
|
@@ -807,10 +852,13 @@ module Sfdown
|
|
|
807
852
|
downloader.apply_folder_times(root)
|
|
808
853
|
|
|
809
854
|
s2 = Time.now - stage2_start
|
|
810
|
-
avg = s2.positive? ? downloader.
|
|
811
|
-
summary = format("Stage 2: %d/%d files, %s in %s (avg %s/s)",
|
|
855
|
+
avg = s2.positive? ? downloader.downloaded_bytes / s2 : 0
|
|
856
|
+
summary = format("Stage 2: %d/%d files, %s downloaded in %s (avg %s/s)",
|
|
812
857
|
downloader.files_done, downloader.total_files,
|
|
813
|
-
Fmt.size(downloader.
|
|
858
|
+
Fmt.size(downloader.downloaded_bytes), Fmt.duration(s2), Fmt.size(avg))
|
|
859
|
+
if downloader.resumed_files.positive?
|
|
860
|
+
summary += format(" (%d resumed, %s)", downloader.resumed_files, Fmt.size(downloader.resumed_bytes))
|
|
861
|
+
end
|
|
814
862
|
if downloader.duped_files.positive?
|
|
815
863
|
summary += format(" (%d duplicates, %s saved)", downloader.duped_files, Fmt.size(downloader.duped_bytes))
|
|
816
864
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sfdown
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- edelkas
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -27,7 +27,8 @@ dependencies:
|
|
|
27
27
|
description: Clones a SourceForge project's directory tree and downloads its files
|
|
28
28
|
by scraping the Files pages (SourceForge has no official API). Runs in two stages
|
|
29
29
|
- map the tree, then download - with a live status bar and optional JSON metadata.
|
|
30
|
-
Supports concurrency
|
|
30
|
+
Supports concurrency, file integrity verification (MD5 / SHA1), file deduplication,
|
|
31
|
+
and resuming interrupted downloads.
|
|
31
32
|
email:
|
|
32
33
|
- edlucasma@gmail.com
|
|
33
34
|
executables:
|