Skip to content

Commit

Permalink
Support for file selection (aka partial downloads)
Browse files Browse the repository at this point in the history
  • Loading branch information
atomashpolskiy committed Feb 21, 2018
1 parent 4a97a35 commit ee062ad
Show file tree
Hide file tree
Showing 21 changed files with 727 additions and 66 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ To allow you test the changes that you've made to the core, **Bt** ships with a

**Bt** has out-of-the-box support for multiple simultaneous torrent sessions with minimal system overhead. 1% CPU and 32M of RAM should be enough for everyone!

### Partial downloads

**Bt** has an API for selecting only a subset of torrent files to download. See the `bt.TorrentClientBuilder.fileSelector(TorrentFileSelector)` client builder method. File selection works for both `.torrent` file-based and magnet link downloads.

### Java 8 CompletableFuture

Client API leverages the asynchronous `java.util.concurrent.CompletableFuture` to provide the most natural way for co-ordinating multiple torrent sessions. E.g. use `CompletableFuture.allOf(client1.startAsync(...), client2.startAsync(...), ...).join()`. Or create a more sophisticated processing pipeline.
Expand Down
4 changes: 4 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ For the latest information visit project web site: http://atomashpolskiy.github.

#### Date:

#### Changes/New Features:

* Support for file selection (aka partial downloads)

#### Bug Fixes/Improvements:

* Avoid creation of unnessary empty dirs when reading from a FileSystemStorageUnit that maps to an absent file
Expand Down
3 changes: 3 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Protocols.setBit(bytes, bt.protocol.BitOrder.LITTLE_ENDIAN, i);
int bit = Protocols.getBit(bytes, bt.protocol.BitOrder.LITTLE_ENDIAN, i);
```

* Semantics of `bt.data.Bitfield.getPiecesRemaining` have been changed. Previously it returned the number of incomplete and unverified pieces (i.e. `getPiecesTotal() - getPiecesComplete()`). Now it returns the number of incomplete and unverified pieces that should NOT be skipped (i.e. the corresponding files are expected to be downloaded). To get the old behavior, you may use `getPiecesIncomplete()`.
* Semantics of `bt.torrent.TorrentSessionState.getPiecesRemaining` have been changed according to the `bt.data.Bitfield.getPiecesRemaining` changes described above. To get the old behavior, you may use `getPiecesIncomplete()`.

## 1.5

* `bt.BaseClientBuilder#runtime(BtRuntime)` is now protected instead of public. Use a factory method `bt.Bt#client(BtRuntime)` to attach the newly created client to a shared runtime.
Expand Down
13 changes: 10 additions & 3 deletions bt-cli/src/main/java/bt/cli/CliClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ public boolean shouldUseRouterBootstrap() {
.storage(storage)
.selector(selector);

SessionStatePrinter printer = options.shouldDisableUi() ?
null : SessionStatePrinter.createKeyInputAwarePrinter(keyBindings);
if (printer == null) {
clientBuilder.fileSelector(new CliFileSelector());
} else {
clientBuilder.fileSelector(new CliFileSelector(printer));
clientBuilder.afterTorrentFetched(printer::setTorrent);
}

if (options.getMetainfoFile() != null) {
clientBuilder = clientBuilder.torrent(toUrl(options.getMetainfoFile()));
} else if (options.getMagnetUri() != null) {
Expand All @@ -144,10 +153,8 @@ public boolean shouldUseRouterBootstrap() {
throw new IllegalStateException("Torrent file or magnet URI is required");
}

clientBuilder.afterTorrentFetched(torrent -> printer.ifPresent(p -> p.setTorrent(torrent)));
this.client = clientBuilder.build();
this.printer = options.shouldDisableUi() ?
Optional.empty() : Optional.of(SessionStatePrinter.createKeyInputAwarePrinter(keyBindings));
this.printer = Optional.ofNullable(printer);
}

private Optional<Integer> getPortOverride() {
Expand Down
93 changes: 93 additions & 0 deletions bt-cli/src/main/java/bt/cli/CliFileSelector.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2016—2018 Andrei Tomashpolskiy and individual contributors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package bt.cli;

import bt.metainfo.TorrentFile;
import bt.torrent.fileselector.SelectionResult;
import bt.torrent.fileselector.TorrentFileSelector;

import java.io.IOException;
import java.util.List;
import java.util.Optional;

public class CliFileSelector extends TorrentFileSelector {
private static final String PROMPT_MESSAGE_FORMAT = "Download '%s'? (hit <Enter> to confirm or <Esc> to skip)";
private static final String ILLEGAL_KEYPRESS_WARNING = "*** Invalid key pressed. Please, use only <Enter> or <Esc> ***";

private final Optional<SessionStatePrinter> printer;
private volatile boolean shutdown;

public CliFileSelector() {
this.printer = Optional.empty();
registerShutdownHook();
}

public CliFileSelector(SessionStatePrinter printer) {
this.printer = Optional.of(printer);
registerShutdownHook();
}

private void registerShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
}

@Override
public List<SelectionResult> selectFiles(List<TorrentFile> files) {
printer.ifPresent(SessionStatePrinter::pause);

List<SelectionResult> results = super.selectFiles(files);

printer.ifPresent(SessionStatePrinter::resume);
return results;
}

@Override
protected SelectionResult select(TorrentFile file) {
while (!shutdown) {
System.out.println(getPromptMessage(file));

try {
switch (System.in.read()) {
case -1: {
throw new IllegalStateException("EOF");
}
case '\n': { // <Enter>
return SelectionResult.select().build();
}
case 0x1B: { // <Esc>
return SelectionResult.skip();
}
default: {
System.out.println(ILLEGAL_KEYPRESS_WARNING);
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}

throw new IllegalStateException("Shutdown");
}

private static String getPromptMessage(TorrentFile file) {
return String.format(PROMPT_MESSAGE_FORMAT, String.join("/", file.getPathElements()));
}

private void shutdown() {
this.shutdown = true;
}
}
91 changes: 72 additions & 19 deletions bt-cli/src/main/java/bt/cli/SessionStatePrinter.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,16 @@ public static SessionStatePrinter createKeyInputAwarePrinter(Collection<KeyStrok
t = new Thread(() -> {
while (!isShutdown()) {
try {
KeyStroke keyStroke = readKeyInput();
if (keyStroke.isCtrlDown() && keyStroke.getKeyType() == KeyType.Character
// don't intercept input when paused
if (super.supressOutput) {
Thread.sleep(1000);
continue;
}

KeyStroke keyStroke = pollKeyInput();
if (keyStroke == null) {
Thread.sleep(100);
} else if (keyStroke.isCtrlDown() && keyStroke.getKeyType() == KeyType.Character
&& keyStroke.getCharacter().equals('c')) {
shutdown();
System.exit(0);
Expand Down Expand Up @@ -95,6 +103,7 @@ public void shutdown() {
private Screen screen;
private TextGraphics graphics;

private volatile boolean supressOutput;
private volatile boolean shutdown;

private Optional<Torrent> torrent;
Expand All @@ -111,7 +120,7 @@ public SessionStatePrinter() {
screen = new TerminalScreen(terminal);
graphics = screen.newTextGraphics();
screen.startScreen();
screen.clear();
clearScreen();

started = System.currentTimeMillis();

Expand All @@ -130,14 +139,13 @@ public boolean isShutdown() {
return shutdown;
}

public void shutdown() {

public synchronized void shutdown() {
if (shutdown) {
return;
}

try {
screen.clear();
clearScreen();
screen.stopScreen();
} catch (Throwable e) {
// ignore
Expand All @@ -153,6 +161,13 @@ public KeyStroke readKeyInput() throws IOException {
return screen.readInput();
}

/**
* non-blocking
*/
public KeyStroke pollKeyInput() throws IOException {
return screen.pollInput();
}

private void printTorrentInfo() {
printTorrentNameAndSize(torrent);
char[] chars = new char[graphics.getSize().getColumns()];
Expand All @@ -170,21 +185,22 @@ private void printTorrentNameAndSize(Optional<Torrent> torrent) {
/**
* call me once per second
*/
public void print(TorrentSessionState sessionState) {

if (shutdown) {
public synchronized void print(TorrentSessionState sessionState) {
if (supressOutput || shutdown) {
return;
}

try {
printTorrentInfo();

long downloaded = sessionState.getDownloaded();
long uploaded = sessionState.getUploaded();

printTorrentNameAndSize(torrent);

String elapsedTime = getElapsedTime();
String remainingTime = getRemainingTime(downloaded - this.downloaded,
sessionState.getPiecesRemaining(), sessionState.getPiecesTotal());
sessionState.getPiecesRemaining(), sessionState.getPiecesNotSkipped());
graphics.putString(0, 2, String.format(DURATION_INFO, elapsedTime, remainingTime));

Rate downRate = new Rate(downloaded - this.downloaded);
Expand All @@ -194,8 +210,10 @@ public void print(TorrentSessionState sessionState) {
upRate.getQuantity(), upRate.getMeasureUnit());
graphics.putString(0, 3, sessionInfo);

double completePercents = getCompletePercentage(sessionState.getPiecesTotal(), sessionState.getPiecesRemaining());
graphics.putString(0, 4, getProgressBar(completePercents));
int completed = sessionState.getPiecesComplete();
double completePercents = getCompletePercentage(sessionState.getPiecesTotal(), completed);
double requiredPercents = getTargetPercentage(sessionState.getPiecesTotal(), completed, sessionState.getPiecesRemaining());
graphics.putString(0, 4, getProgressBar(completePercents, requiredPercents));

boolean complete = (sessionState.getPiecesRemaining() == 0);
if (complete) {
Expand Down Expand Up @@ -252,23 +270,58 @@ private static String formatDuration(Duration duration) {
return seconds < 0 ? "-" + positive : positive;
}

private String getProgressBar(double completePercents) throws IOException {
private String getProgressBar(double completePercents, double requiredPercents) throws IOException {
int completeInt = (int) completePercents;
int requiredInt = (int) requiredPercents;

int width = graphics.getSize().getColumns() - 25;
if (width < 0) {
return "Progress: " + completeInt + "%";
return "Progress: " + completeInt + "% (req.: " + requiredInt + "%)";
}

String s = "Progress: [%-" + width + "s] %d%%";
char[] bar = new char[width];
double shrinkFactor = width / 100d;
char[] chars = new char[(int) (completeInt * shrinkFactor)];
Arrays.fill(chars, '#');
return String.format(s, String.valueOf(chars), completeInt);
int bound = (int) (completeInt * shrinkFactor);
Arrays.fill(bar, 0, bound, '#');
Arrays.fill(bar, bound, bar.length, ' ');
if (completeInt != requiredInt) {
bar[(int) (requiredInt * shrinkFactor) - 1] = '|';
}
return String.format(s, String.valueOf(bar), completeInt);
}

private double getCompletePercentage(int total, int completed) {
return completed / ((double) total) * 100;
}

private double getCompletePercentage(int total, int remaining) {
return ((total - remaining) / ((double) total) * 100);
private double getTargetPercentage(int total, int completed, int remaining) {
return (completed + remaining) / ((double) total) * 100;
}

private void clearScreen() {
try {
this.screen.clear();
this.screen.refresh();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public synchronized void pause() {
if (supressOutput) {
return;
}
this.supressOutput = true;
clearScreen();
}

public synchronized void resume() {
if (!supressOutput) {
return;
}
this.supressOutput = false;
clearScreen();
}

private static class Rate {
Expand Down
27 changes: 24 additions & 3 deletions bt-core/src/main/java/bt/TorrentClientBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import bt.processor.torrent.TorrentContext;
import bt.runtime.BtRuntime;
import bt.torrent.PieceSelectionStrategy;
import bt.torrent.fileselector.TorrentFileSelector;
import bt.torrent.selector.PieceSelector;
import bt.torrent.selector.RarestFirstSelector;
import bt.torrent.selector.SelectorAdapter;
Expand All @@ -50,6 +51,7 @@ public class TorrentClientBuilder<B extends TorrentClientBuilder> extends BaseCl
private Supplier<Torrent> torrentSupplier;
private MagnetUri magnetUri;

private TorrentFileSelector fileSelector;
private PieceSelector pieceSelector;

private List<Consumer<Torrent>> torrentConsumers;
Expand Down Expand Up @@ -196,6 +198,12 @@ public B stopWhenDownloaded() {
return (B) this;
}

/**
* Provide a callback to invoke when torrent's metadata has been fetched.
*
* @param torrentConsumer Callback to invoke when torrent's metadata has been fetched
* @since 1.5
*/
@SuppressWarnings("unchecked")
public B afterTorrentFetched(Consumer<Torrent> torrentConsumer) {
if (torrentConsumers == null) {
Expand All @@ -205,17 +213,30 @@ public B afterTorrentFetched(Consumer<Torrent> torrentConsumer) {
return (B) this;
}

/**
* Provide a file selector for partial download of the torrent.
*
* @param fileSelector A file selector for partial download of the torrent.
* @since 1.7
*/
@SuppressWarnings("unchecked")
public B fileSelector(TorrentFileSelector fileSelector) {
Objects.requireNonNull(fileSelector, "Missing file selector");
this.fileSelector = fileSelector;
return (B) this;
}

@Override
protected ProcessingContext buildProcessingContext(BtRuntime runtime) {
Objects.requireNonNull(storage, "Missing data storage");

ProcessingContext context;
if (torrentUrl != null) {
context = new TorrentContext(pieceSelector, storage, () -> fetchTorrentFromUrl(runtime, torrentUrl));
context = new TorrentContext(pieceSelector, fileSelector, storage, () -> fetchTorrentFromUrl(runtime, torrentUrl));
} else if (torrentSupplier != null) {
context = new TorrentContext(pieceSelector, storage, torrentSupplier);
context = new TorrentContext(pieceSelector, fileSelector, storage, torrentSupplier);
} else if (this.magnetUri != null) {
context = new MagnetContext(magnetUri, pieceSelector, storage);
context = new MagnetContext(magnetUri, pieceSelector, fileSelector, storage);
} else {
throw new IllegalStateException("Missing torrent supplier, torrent URL or magnet URI");
}
Expand Down
Loading

0 comments on commit ee062ad

Please sign in to comment.