From 39c8a218de68b32ff9182d59ae88553df950aca5 Mon Sep 17 00:00:00 2001 From: karimch-50 Date: Sat, 3 Oct 2026 20:21:56 +0000 Subject: [PATCH] feat(torrent): enhance movie range configuration and add startup buffer property --- .../selector/SequentialPieceSelector.java | 66 +++++++++++++-- .../worker/TorrentDownloadWorker.java | 83 +++++++++++++++++-- .../streaming/config/StreamProperties.java | 3 + .../serviceImpl/StreamServiceImpl.java | 5 +- src/main/resources/application.properties | 2 +- .../selector/SequentialPieceSelectorTest.java | 19 +++++ src/test/resources/application.properties | 1 + 7 files changed, 159 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelector.java b/src/main/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelector.java index 46042be..6b78e7c 100644 --- a/src/main/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelector.java +++ b/src/main/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelector.java @@ -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; @@ -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; } /** @@ -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; } @@ -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); } @@ -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) { diff --git a/src/main/java/com/filmexa/stream/modules/download/worker/TorrentDownloadWorker.java b/src/main/java/com/filmexa/stream/modules/download/worker/TorrentDownloadWorker.java index 5f8a85b..bd4a25f 100644 --- a/src/main/java/com/filmexa/stream/modules/download/worker/TorrentDownloadWorker.java +++ b/src/main/java/com/filmexa/stream/modules/download/worker/TorrentDownloadWorker.java @@ -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; @@ -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; @@ -136,8 +139,13 @@ public Optional 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); @@ -150,11 +158,6 @@ public Optional 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) { @@ -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) @@ -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 @@ -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 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 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); + } } diff --git a/src/main/java/com/filmexa/stream/modules/streaming/config/StreamProperties.java b/src/main/java/com/filmexa/stream/modules/streaming/config/StreamProperties.java index 448a281..25accba 100644 --- a/src/main/java/com/filmexa/stream/modules/streaming/config/StreamProperties.java +++ b/src/main/java/com/filmexa/stream/modules/streaming/config/StreamProperties.java @@ -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; } diff --git a/src/main/java/com/filmexa/stream/modules/streaming/serviceImpl/StreamServiceImpl.java b/src/main/java/com/filmexa/stream/modules/streaming/serviceImpl/StreamServiceImpl.java index e90a793..12f6acf 100644 --- a/src/main/java/com/filmexa/stream/modules/streaming/serviceImpl/StreamServiceImpl.java +++ b/src/main/java/com/filmexa/stream/modules/streaming/serviceImpl/StreamServiceImpl.java @@ -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 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a19b78e..5c7b468 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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} @@ -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] - diff --git a/src/test/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelectorTest.java b/src/test/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelectorTest.java index 5a3e0b5..bc34fd6 100644 --- a/src/test/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelectorTest.java +++ b/src/test/java/com/filmexa/stream/modules/download/selector/SequentialPieceSelectorTest.java @@ -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 order = selector.getNextPieces(availablePieces, null).boxed().toList(); + + assertEquals(4, order.get(0)); + assertEquals(13, order.get(1)); + assertEquals(9, order.get(2)); + } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 6a9c7b2..f50fd93 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -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}