Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public class SequentialPieceSelector implements PieceSelector {
private int totalPieces;
private volatile boolean isMp4;

/** Inclusive piece range occupied by the actual movie inside a multi-file torrent. */
private volatile int movieFirstPiece = 0;
private volatile int movieLastPiece = -1;
private volatile boolean movieRangeConfigured;

/** Where playback is, in pieces. Everything from here on is wanted first. */
private volatile int playheadPiece = 0;

Expand All @@ -30,6 +35,33 @@ public SequentialPieceSelector(boolean isMp4) {
@Override
public void initSelector(int totalPieces) {
this.totalPieces = totalPieces;
if (movieLastPiece < 0) {
movieLastPiece = totalPieces - 1;
}
}

/**
* Supplies the movie's real piece range once torrent metadata is available.
* Fractions used by playback must be relative to this range, not to samples,
* subtitles and other files that happen to share the torrent.
*/
public void configureMovieRange(int firstPiece, int lastPiece, boolean mp4) {
if (totalPieces <= 0) {
return;
}
int first = Math.max(0, Math.min(firstPiece, totalPieces - 1));
int last = Math.max(first, Math.min(lastPiece, totalPieces - 1));
this.movieFirstPiece = first;
this.movieLastPiece = last;
this.isMp4 = mp4;
this.movieRangeConfigured = true;
if (playheadPiece < first || playheadPiece > last) {
this.playheadPiece = first;
}
}

public boolean isMovieRangeConfigured() {
return movieRangeConfigured;
}

/**
Expand All @@ -45,11 +77,25 @@ public int seekToFraction(double fraction) {
}

double clamped = Math.min(1.0, Math.max(0.0, fraction));
int target = Math.min(pieces - 1, (int) (clamped * pieces));
int first = movieFirstPiece;
int last = movieLastPiece >= first ? movieLastPiece : pieces - 1;
int moviePieces = last - first + 1;
int target = Math.min(last, first + (int) (clamped * moviePieces));
this.playheadPiece = target;
return target;
}

public int pieceAtFraction(double fraction) {
int pieces = totalPieces;
if (pieces <= 0) {
return 0;
}
double clamped = Math.min(1.0, Math.max(0.0, fraction));
int first = movieFirstPiece;
int last = movieLastPiece >= first ? movieLastPiece : pieces - 1;
return Math.min(last, first + (int) (clamped * (last - first + 1)));
}

public int getPlayheadPiece() {
return playheadPiece;
}
Expand All @@ -62,13 +108,15 @@ public IntStream getNextPieces(BitSet relevantChunks, PieceStatistics pieceStati

IntStream.Builder builder = IntStream.builder();

// The header has to come first whatever the playhead says - nothing can be
// The movie header has to come first whatever the playhead says - piece zero
// may belong to an nfo or sample in a multi-file torrent.
// decoded, or even probed, without it.
if (relevantChunks.get(0)) {
builder.add(0);
int firstPiece = movieFirstPiece;
if (relevantChunks.get(firstPiece)) {
builder.add(firstPiece);
}

int lastPiece = totalPieces - 1;
int lastPiece = movieLastPiece >= firstPiece ? movieLastPiece : totalPieces - 1;
if (isMp4 && totalPieces > 1 && relevantChunks.get(lastPiece)) {
builder.add(lastPiece);
}
Expand All @@ -78,20 +126,20 @@ public IntStream getNextPieces(BitSet relevantChunks, PieceStatistics pieceStati
// From the playhead to the end of the file...
relevantChunks.stream()
.filter(i -> i >= playhead)
.filter(i -> shouldKeepPiece(i, lastPiece))
.filter(i -> shouldKeepPiece(i, firstPiece, lastPiece))
.forEach(builder::add);

// ...then everything skipped over, so a seek does not abandon it for good.
relevantChunks.stream()
.filter(i -> i < playhead)
.filter(i -> shouldKeepPiece(i, lastPiece))
.filter(i -> shouldKeepPiece(i, firstPiece, lastPiece))
.forEach(builder::add);

return builder.build();
}

private boolean shouldKeepPiece(int pieceIndex, int lastPiece) {
if (pieceIndex == 0) {
private boolean shouldKeepPiece(int pieceIndex, int firstPiece, int lastPiece) {
if (pieceIndex == firstPiece) {
return false;
}
if (isMp4 && pieceIndex == lastPiece) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import bt.data.Storage;
import bt.data.file.FileSystemStorage;
import bt.data.Bitfield;
import bt.data.DataDescriptor;
import bt.magnet.MagnetUriParser;
import bt.metainfo.TorrentFile;
import bt.metainfo.TorrentId;
import bt.runtime.BtRuntime;
import bt.torrent.TorrentDescriptor;
Expand All @@ -30,6 +32,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.HashSet;
import com.filmexa.stream.modules.download.enums.DownloadStatus;
import java.time.LocalDateTime;

Expand Down Expand Up @@ -136,8 +139,13 @@ public Optional<Boolean> isRangeDownloaded(Long movieId, double fromFraction, do
return Optional.empty();
}

int first = pieceAt(fromFraction, total);
int last = pieceAt(toFraction, total);
SequentialPieceSelector selector = selectors.get(movieId);
if (selector == null) {
return Optional.empty();
}
configureMovieRange(movieId, selector, runtime, torrentId);
int first = selector.pieceAtFraction(fromFraction);
int last = selector.pieceAtFraction(toFraction);
for (int piece = first; piece <= last; piece++) {
if (!bitfield.isComplete(piece)) {
return Optional.of(false);
Expand All @@ -150,11 +158,6 @@ public Optional<Boolean> isRangeDownloaded(Long movieId, double fromFraction, do
}
}

private int pieceAt(double fraction, int totalPieces) {
double clamped = Math.min(1.0, Math.max(0.0, fraction));
return Math.min(totalPieces - 1, (int) (clamped * totalPieces));
}

public void stopDownload(Long movieId) {
BtClient client = activeClients.remove(movieId);
if (client != null) {
Expand Down Expand Up @@ -210,11 +213,12 @@ private void runDownload(Long movieId, String magnetUrl) {
// --- STEP 1: Directory & Format Detection ---
Path movieDir = Paths.get(videoStoragePath, String.valueOf(movieId));
Files.createDirectories(movieDir);
boolean isMp4 = magnetUrl.toLowerCase().contains(".mp4");

// --- STEP 2: Configure & Build BtClient ---
Storage storage = new FileSystemStorage(movieDir);
SequentialPieceSelector selector = new SequentialPieceSelector(isMp4);
// The magnet name is not reliable enough to identify the container. The
// actual movie and its piece range are selected when metadata arrives.
SequentialPieceSelector selector = new SequentialPieceSelector(false);
selectors.put(movieId, selector);
BtClient client = Bt.client(lease.runtime())
.storage(storage)
Expand Down Expand Up @@ -248,8 +252,14 @@ private void runDownload(Long movieId, String magnetUrl) {
}
AtomicLong lastPeerTrigger = new AtomicLong(0);
AtomicLong lastProgressPersist = new AtomicLong(System.currentTimeMillis());
AtomicBoolean movieRangeConfigured = new AtomicBoolean(false);
BtRuntime runtime = lease.runtime();

CompletableFuture<?> future = client.startAsync(sessionState -> {
if (!movieRangeConfigured.get()
&& configureMovieRange(movieId, selector, runtime, torrentId)) {
movieRangeConfigured.set(true);
}
Long downloaded = sessionState.getDownloaded();
Long left = sessionState.getLeft();
// A negative "left" means the metadata has not been fetched yet, so
Expand Down Expand Up @@ -397,4 +407,59 @@ private TorrentId torrentIdOf(String magnetUrl) {
return null;
}
}

private boolean configureMovieRange(Long movieId, SequentialPieceSelector selector,
BtRuntime runtime, TorrentId torrentId) {
if (selector.isMovieRangeConfigured()) {
return true;
}
if (torrentId == null) {
return false;
}
try {
Optional<TorrentDescriptor> descriptor =
runtime.service(TorrentRegistry.class).getDescriptor(torrentId);
if (descriptor.isEmpty() || descriptor.get().getDataDescriptor() == null) {
return false;
}
DataDescriptor data = descriptor.get().getDataDescriptor();
TorrentFile movie = largestVideoFile(data);
if (movie == null) {
return false;
}
int first = -1;
int last = -1;
int total = data.getBitfield().getPiecesTotal();
for (int piece = 0; piece < total; piece++) {
if (data.getFilesForPiece(piece).contains(movie)) {
if (first < 0) first = piece;
last = piece;
}
}
if (first >= 0) {
String name = movie.getPathElements().get(movie.getPathElements().size() - 1);
selector.configureMovieRange(first, last, name.toLowerCase().endsWith(".mp4"));
log.info("Movie {} occupies torrent pieces {}..{} ({})", movieId, first, last, name);
return true;
}
} catch (Exception e) {
log.debug("Could not map movie {} to its torrent pieces: {}", movieId, e.getMessage());
}
return false;
}

@SuppressWarnings("deprecation")
private TorrentFile largestVideoFile(DataDescriptor data) {
Set<TorrentFile> files = new HashSet<>();
for (int piece = 0; piece < data.getBitfield().getPiecesTotal(); piece++) {
files.addAll(data.getFilesForPiece(piece));
}
return files.stream()
.filter(file -> {
String name = file.getPathElements().get(file.getPathElements().size() - 1).toLowerCase();
return name.matches(".*\\.(mp4|mkv|avi|mov|m4v|webm|wmv|flv|mpg|mpeg|ts)$");
})
.max(java.util.Comparator.comparingLong(TorrentFile::getSize))
.orElse(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ public class StreamProperties {
private long tokenTtlMinutes = 180;

private int readaheadSeconds = 15;

/** Initial media that must be present before playback is started. */
private int startupBufferSeconds = 10;
}
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,10 @@ private MediaInfo mediaInfo(Long movieId) {
*/
private void requireDownloaded(Long movieId, MediaInfo info, int segmentIndex) {
double segmentEnd = (double) (segmentIndex + 1) * properties.getSegmentSeconds();
double required = Math.min(segmentEnd + properties.getReadaheadSeconds(), info.durationSeconds());
double bufferedEnd = segmentIndex == 0
? Math.max(segmentEnd, properties.getStartupBufferSeconds())
: segmentEnd + properties.getReadaheadSeconds();
double required = Math.min(bufferedEnd, info.durationSeconds());
double segmentStart = (double) segmentIndex * properties.getSegmentSeconds();

// Ask the torrent which pieces it actually holds. After a seek the data is no
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ app.stream.token-secret=${STREAM_TOKEN_SECRET:${SECURITY_JWT_SECRET_KEY}}
app.stream.token-ttl-minutes=${STREAM_TOKEN_TTL_MINUTES:180}
# How many seconds of video must already be on disk ahead of a requested segment
app.stream.readahead-seconds=${STREAM_READAHEAD_SECONDS:15}
app.stream.startup-buffer-seconds=${STREAM_STARTUP_BUFFER_SECONDS:10}

# Cleanup: movies unwatched for this long are erased (42 subject: one month)
app.cleanup.retention-days=${CLEANUP_RETENTION_DAYS:30}
Expand Down Expand Up @@ -121,4 +122,3 @@ logging.level.bt.peer.ScheduledPeerSource=${BT_PEER_LOG_LEVEL:ERROR}
logging.level.bt.net.DataReceivingLoop=${BT_NET_LOG_LEVEL:OFF}

spring.config.import=optional:file:.env[.properties]

Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,23 @@ void seekBeforeTheMetadataArrivesIsIgnored() {
assertEquals(0, selector.seekToFraction(0.75));
assertEquals(0, selector.getPlayheadPiece());
}

@Test
void movieFractionsAreMappedInsideItsMultiFileTorrentRange() {
SequentialPieceSelector selector = new SequentialPieceSelector(false);
selector.initSelector(20);
selector.configureMovieRange(4, 13, true);

assertEquals(4, selector.pieceAtFraction(0));
assertEquals(9, selector.seekToFraction(0.5));
assertEquals(13, selector.pieceAtFraction(1));

BitSet availablePieces = new BitSet(20);
availablePieces.set(0, 20);
List<Integer> order = selector.getNextPieces(availablePieces, null).boxed().toList();

assertEquals(4, order.get(0));
assertEquals(13, order.get(1));
assertEquals(9, order.get(2));
}
}
1 change: 1 addition & 0 deletions src/test/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ app.stream.token-secret=${STREAM_TOKEN_SECRET:${SECURITY_JWT_SECRET_KEY}}
app.stream.token-ttl-minutes=${STREAM_TOKEN_TTL_MINUTES:180}
# How many seconds of video must already be on disk ahead of a requested segment
app.stream.readahead-seconds=${STREAM_READAHEAD_SECONDS:15}
app.stream.startup-buffer-seconds=${STREAM_STARTUP_BUFFER_SECONDS:10}

# Cleanup: movies unwatched for this long are erased (42 subject: one month)
app.cleanup.retention-days=${CLEANUP_RETENTION_DAYS:30}
Expand Down
Loading