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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44df4c3a6fc531cc1ef2f8454abf8d6224980ee4d896d27508741df69f3aab11
4
- data.tar.gz: 562025b5cda6afd162a623cfec735badf2f8fc5aad69b4588392d8b24d8a6299
3
+ metadata.gz: 5ac26367a5dfb5104d0765390e182530c231d080825c2af68ac3cd55746eefb7
4
+ data.tar.gz: 1f6198ec690293e27ee67165c1ae6e131a08564398d529ae5107c65cfce66eae
5
5
  SHA512:
6
- metadata.gz: 9df2bf33aca1160bfd233dfcc9cc8c4cd91bf8341b2ddffbec7ced700b972bdc8bd9c8350b5e602ce0116df989f694a2bf3570fa7551f4a0f64e7d19a2b3d3ce
7
- data.tar.gz: 6ecd5025e1afc37fd265c91e444c47773d6b8d3d648946d7e3ba454f1f9dab897fc8d6b86f0aeef789c86a8b8fd05f549ba6d1aad09a558f828542516b9c4920
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
+ [![Gem Version](https://img.shields.io/gem/v/sfdown.svg)](https://rubygems.org/gems/sfdown)
4
+ [![Gem](https://img.shields.io/gem/dt/sfdown.svg)](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) and [file deduplication](#file-deduplication).
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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sfdown
4
- VERSION = "0.4.0"
5
- DATE = "2026/07/24"
4
+ VERSION = "0.4.1"
5
+ DATE = "2026/07/25"
6
6
  end
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
- @files_done = 0
424
- @bytes_done = 0
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
- def files_done = @mutex.synchronize { @files_done }
433
- def bytes_done = @mutex.synchronize { @bytes_done }
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 to fetch
440
- # and the duplicates that will be cloned from them. total_bytes counts only
441
- # what will actually cross the network, so the progress bar stays honest.
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
- # The first node carrying a given checksum is downloaded; any later node with
486
- # the same one is a duplicate of it. Files SF gives no checksum for can't be
487
- # deduped, so they're always fetched.
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 (source = key && sources[key])
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 { @bytes_done += chunk.bytesize }
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
- @files_done += 1
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
- @files_done += 1
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
- speed = s2.positive? ? dl.bytes_done / s2 : 0
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.bytes_done / s2 : 0
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.bytes_done), Fmt.duration(s2), Fmt.size(avg))
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.0
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-24 00:00:00.000000000 Z
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 and integrity verification (MD5 / SHA1).
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: