Skip to content

Add HLS streaming for instant playback while downloading#58

Open
hauxir wants to merge 49 commits intomasterfrom
hls-streaming
Open

Add HLS streaming for instant playback while downloading#58
hauxir wants to merge 49 commits intomasterfrom
hls-streaming

Conversation

@hauxir
Copy link
Copy Markdown
Owner

@hauxir hauxir commented Apr 27, 2026

Summary

  • Add an HLS streaming pipeline that pipes sequential torrent bytes into ffmpeg so playback can start before the download/MP4 conversion completes (MKV/AVI/TS containers; MP4/MOV still wait for full download since the mov demuxer needs seeking).
  • Keep the existing MP4 conversion pipeline as the primary output and seamlessly switch the player from HLS to MP4 once conversion finishes, preserving playback position and subtitle selection.
  • Expose subtitles via the HLS master playlist (EXT-X-MEDIA TYPE=SUBTITLES) so hls.js manages text tracks natively, with language normalization and disambiguation for duplicate-language tracks.
  • Harden error handling: codec tagging for HEVC fmp4, frag_keyframe + reset_timestamps for clean seeking, retry/recovery on hls.js network/media errors, and a 50MB sequential-byte threshold gating the stream button.

Test plan

  • Stream an MKV torrent before it finishes downloading and confirm playback starts once 50MB is sequential.
  • Verify seamless HLS→MP4 switch when conversion completes (position + selected subtitle preserved).
  • Subtitle language preference applies on initial load and survives the HLS→MP4 switch; duplicate-language tracks are distinguishable.
  • HEVC source plays in Chrome (hvc1 tag) and falls back cleanly to the loading screen if HLS fails.
  • MP4 source files do not show the stream button (need seeking, can't pipe).

🤖 Generated with Claude Code

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Add HLS streaming for instant playback while downloading non-MP4 video files

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add HLS streaming pipeline for instant video playback while downloading non-MP4 files
  - Pipe sequential torrent bytes into ffmpeg for fMP4 segment generation
  - Support MKV/AVI/TS containers; MP4/MOV wait for full download (require seeking)
  - Seamlessly switch from HLS to MP4 once conversion completes, preserving playback position
• Expose subtitles via HLS master playlist with EXT-X-MEDIA tracks for native hls.js management
  - Extract embedded subtitles as VTT files during streaming
  - Language normalization and disambiguation for duplicate-language tracks
• Harden error handling and add recovery mechanisms
  - Codec tagging for HEVC (hvc1) and H.264 (avc1) fMP4 segments
  - Retry/recovery on hls.js network and media errors with max recovery limit
  - Delete incomplete m3u8 on ffmpeg failure to trigger daemon retry
  - Self-healing: finalize orphaned m3u8 files by switching EVENT→VOD and appending ENDLIST
• Add UI stream button (▶) for eligible files with 50MB sequential-byte threshold
  - Show download progress badge in player topbar during HLS playback
  - Disable stream button for MP4 files (cannot pipe, require seeking)
Diagram
flowchart LR
  A["Torrent Download"] -->|Sequential Bytes| B["HLS Streamer"]
  B -->|ffmpeg stdin| C["fMP4 Segments"]
  C -->|hls.js Player| D["Early Playback"]
  A -->|Full Download| E["MP4 Converter"]
  E -->|MP4 Ready| F["Seamless Switch"]
  D -->|Position Preserved| F
  B -->|Extract VTT| G["Subtitles"]
  G -->|Master Playlist| H["Track Selection"]
Loading

Grey Divider

File Changes

1. app/app.py ✨ Enhancement +13/-1

Add HLS streaming fields and endpoint to API responses

app/app.py


2. app/rapidbaydaemon.py ✨ Enhancement +160/-31

Implement HLS streaming orchestration and subtitle extraction

app/rapidbaydaemon.py


3. app/settings.py ⚙️ Configuration changes +4/-0

Add HLS segment duration and start threshold configuration

app/settings.py


View more (8)
4. app/torrent.py ✨ Enhancement +28/-0

Add sequential byte calculation and sequential download flag

app/torrent.py


5. app/video_conversion.py ✨ Enhancement +258/-5

Implement HLSStreamer class with pipe feeding and master playlist generation

app/video_conversion.py


6. app/frontend/app.js ✨ Enhancement +161/-43

Add hls.js integration with error recovery and subtitle track management

app/frontend/app.js


7. app/frontend/index.html ✨ Enhancement +8/-3

Load hls.js library and add stream button to download screen

app/frontend/index.html


8. app/frontend/style.css ✨ Enhancement +1/-0

Add download progress badge styling for player topbar

app/frontend/style.css


9. app/run.sh ⚙️ Configuration changes +5/-3

Make listen port configurable via environment variable

app/run.sh


10. Dockerfile ⚙️ Configuration changes +1/-0

Remove default nginx site configuration

Dockerfile


11. nginx.conf ⚙️ Configuration changes +11/-1

Add MIME types for HLS and video segments, make port configurable

nginx.conf


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Shell ffmpeg injection🐞 Bug ⛨ Security
Description
RapidBayDaemon._download_external_subtitles builds an ffmpeg command string using
filesystem-derived paths and executes it with shell=True, allowing command injection if a
torrent-derived basename or subtitle filename contains quotes/shell metacharacters. This can lead to
arbitrary command execution under the app user.
Code

app/rapidbaydaemon.py[R410-426]

+        # Copy downloaded .srt files to output dir as .vtt for HLS playback
+        dirname = os.path.dirname(filepath)
+        basename = os.path.basename(filepath)
+        basename_without_ext = os.path.splitext(basename)[0]
+        for srt_file in os.listdir(dirname):
+            if srt_file.endswith(".srt") and srt_file.startswith(basename_without_ext):
+                srt_path = os.path.join(dirname, srt_file)
+                srt_stem = os.path.splitext(srt_file)[0]
+                lang = srt_stem[len(basename_without_ext):].lstrip(".")
+                vtt_name = f"{basename_without_ext}_{lang}.vtt" if lang else srt_stem + ".vtt"
+                vtt_path = os.path.join(output_dir, vtt_name)
+                if not os.path.isfile(vtt_path):
+                    from subprocess import Popen
+                    Popen(
+                        f'ffmpeg -nostdin -v quiet -i "{srt_path}" "{vtt_path}"',
+                        shell=True,
+                    ).wait()
Evidence
The PR introduces a new shell=True invocation where the interpolated arguments are derived from
filepath (torrent filename influences basename) and directory listing results, making them
attacker-controlled in the torrent threat model.

app/rapidbaydaemon.py[401-427]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_download_external_subtitles()` executes ffmpeg via a shell string with paths derived from torrent-controlled filenames. A filename containing quotes or shell metacharacters can break out of the quoted string and inject commands.
### Issue Context
This code runs on the server and processes filenames originating from torrents / downloaded subtitles, which should be treated as untrusted.
### Fix
- Replace the `Popen(f'ffmpeg ...', shell=True)` call with a argv-list invocation (`shell=False`).
- Consider switching to `subprocess.run([...], check=False)` for simplicity.
- Ensure paths are passed as raw arguments (no manual quoting).
### Fix Focus Areas
- app/rapidbaydaemon.py[401-427]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Stream start may lie🐞 Bug ≡ Correctness
Description
start_hls_stream() returns True once the sequential-byte threshold is met even though
HLSStreamer.start_stream() may immediately return without marking the stream active when
MAX_PARALLEL_CONVERSIONS capacity is reached. This makes /api/.../stream report success when no
stream will run.
Code

app/rapidbaydaemon.py[R355-382]

+    def start_hls_stream(self, magnet_hash: str, filename: str) -> bool:
+        """Start HLS streaming for a file. Returns True if stream was started or already active."""
+        is_video = os.path.splitext(filename)[1][1:] in settings.VIDEO_EXTENSIONS
+        if not is_video:
+            return False
+        m3u8 = _m3u8_path(magnet_hash, filename)
+        if os.path.isfile(m3u8) or self.hls_streamer.active_streams.get(m3u8):
+            return True  # Already streaming or complete
+        h = self.torrent_client.torrents.get(magnet_hash)
+        if not h or not h.has_metadata():
+            return False
+        i, f = torrent.get_index_and_file_from_files(h, filename)
+        if i is None or f is None:
+            return False
+        available_bytes = self._get_available_bytes(magnet_hash, filename)
+        if available_bytes < settings.HLS_START_THRESHOLD:
+            return False
+        filepath = os.path.join(settings.DOWNLOAD_DIR, magnet_hash, f.path)
+        output_dir = _get_output_dir(magnet_hash)
+        os.makedirs(output_dir, exist_ok=True)
+        self.hls_streamer.start_stream(
+            filepath,
+            output_dir,
+            lambda _mh=magnet_hash, _fn=filename: self._get_available_bytes(_mh, _fn),
+            total_file_size=f.size,
+            m3u8_filename=_m3u8_filename(filename),
+        )
+        return True
Evidence
The daemon endpoint always returns True after invoking the threaded start, but the streamer has an
early capacity return before it adds an entry to active_streams, meaning the stream is neither
started nor active in that case.

app/rapidbaydaemon.py[355-382]
app/video_conversion.py[225-247]
app/settings.py[50-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`start_hls_stream()` reports `started=True` even when the stream thread will immediately exit due to `MAX_PARALLEL_CONVERSIONS`.
### Issue Context
`HLSStreamer.start_stream()` is `@threaded`, so the caller can’t observe the early-return decision unless it performs the capacity check itself (or the streamer exposes a synchronous "reserve slot" API).
### Fix
- Add a capacity check in `RapidBayDaemon.start_hls_stream()` before calling `hls_streamer.start_stream()` (e.g., compare `len(active_streams)` against `MAX_PARALLEL_CONVERSIONS`).
- Alternatively, refactor `HLSStreamer` so the capacity check and `active_streams[m3u8_path]=True` reservation happen synchronously in a non-threaded method, and only the ffmpeg work is threaded.
- Only return `True` when a stream is already active/complete or was successfully reserved/started.
### Fix Focus Areas
- app/rapidbaydaemon.py[355-382]
- app/video_conversion.py[225-247]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Pipe feeder tight loop🐞 Bug ➹ Performance
Description
HLSStreamer._pipe_feeder breaks its inner loop when f.read() returns empty while `available >
bytes_written`, and then immediately retries without any sleep, which can cause a busy loop. This
can happen when the sequential-byte estimator exceeds the bytes currently flushed/available via the
file read.
Code

app/video_conversion.py[R350-356]

+                    to_read = available - bytes_written
+                    while to_read > 0:
+                        chunk_size = min(to_read, PIPE_READ_CHUNK)
+                        data = f.read(chunk_size)
+                        if not data:
+                            break
+                        try:
Evidence
When available > bytes_written, the outer loop skips the time.sleep(0.5) guard. If f.read()
returns b'' (EOF because file isn’t extended yet), the code breaks only the inner loop and
immediately recomputes to_read again, potentially spinning. The sequential-byte computation is
piece-based and does not reference current on-disk file size, so it can overestimate relative to
readable bytes.

app/video_conversion.py[339-366]
app/torrent.py[255-280]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The pipe feeder can enter a tight retry loop when it expects more bytes (`available > bytes_written`) but the file read returns empty (EOF).
### Issue Context
`available` is derived from torrent piece state / HTTP progress, not from the file’s current readable size.
### Fix
- If `f.read()` returns empty, add a small sleep and `continue` the outer loop (instead of immediately recomputing without delay).
- Consider capping `available` by `os.path.getsize(filepath)` to ensure feeder never tries to read past current file size.
- Optionally track “no progress” duration and abort the stream if stalled.
### Fix Focus Areas
- app/video_conversion.py[325-366]
- app/torrent.py[255-280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. No HLS wait timeout🐞 Bug ☼ Reliability
Description
For non-pipe inputs, HLSStreamer.start_stream waits in a loop until `get_sequential_bytes() >=
total_file_size with no timeout, while the stream is already counted in active_streams`. A stalled
download can keep a stream slot occupied indefinitely, preventing additional streams from starting.
Code

app/video_conversion.py[R291-294]

+                    # MP4/MOV need seeking — wait for full file, then use direct input
+                    while get_sequential_bytes() < total_file_size:
+                        time.sleep(1)
+                    proc = Popen(ffmpeg_cmd, stdin=DEVNULL, stdout=DEVNULL, stderr=stderr_log)
Evidence
The code marks the stream active before entering a potentially unbounded wait loop, and
active_streams entries are only removed in finally, so a never-completing wait keeps capacity
consumed (the streamer also enforces len(active_streams) >= MAX_PARALLEL_CONVERSIONS).

app/video_conversion.py[239-247]
app/video_conversion.py[290-295]
app/video_conversion.py[321-323]
app/settings.py[55-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Non-pipe HLS startup blocks waiting for the full file with no timeout, keeping the stream marked active and consuming the limited active-stream capacity.
### Issue Context
Even if the UI doesn’t expose streaming for MP4/MOV, the backend API can still be called, and stalled downloads can accumulate waiting threads/active slots.
### Fix
- Add a max wait timeout (and/or max “no progress” duration) to the `while get_sequential_bytes() < total_file_size` loop.
- If timed out, remove the `active_streams` entry and exit cleanly.
- Consider refusing HLS for non-pipe formats at the daemon API level unless explicitly allowed.
### Fix Focus Areas
- app/video_conversion.py[239-247]
- app/video_conversion.py[290-295]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread app/rapidbaydaemon.py Outdated
@hauxir hauxir force-pushed the hls-streaming branch 2 times, most recently from e489ef9 to 408204c Compare April 29, 2026 09:37
hauxir and others added 25 commits April 30, 2026 12:31
Stream video via HLS while torrents download instead of waiting for
full download + conversion. Key changes:

- Pipe feeder reads sequential torrent data into ffmpeg stdin (MKV/AVI/TS)
- MP4/MOV containers wait for full download (mov demuxer needs seeking)
- fMP4 segments (.m4s) for universal codec support (H.264 + HEVC)
- EVENT playlist during streaming, rewritten to VOD on completion
- hls.js for playback (Chrome native HLS lacks EVENT seeking support)
- Per-file segment naming to avoid collisions in multi-file torrents
- Parallel subtitle extraction (VTT served via <track> elements)
- Auto-enable captions from localStorage when tracks arrive late
- Error handling: delete m3u8 on ffmpeg failure so daemon retries
- Self-healing: finalize orphaned m3u8 files missing ENDLIST

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keep MP4 as the primary output format with the full conversion
pipeline (download → subtitles → convert → ready). HLS streaming
is now an optional feature triggered via a UI button for streaming
while downloading non-MP4 video files.

- Restore VideoConverter class alongside HLSStreamer
- Add POST /api/magnet/{hash}/{filename}/stream endpoint
- Add stream button (▶) in download screen for eligible files
- Show download progress badge in player topbar during HLS playback
- HLS info (hls_filename, hls_subtitles) returned as overlay fields
  on the normal status response, not as a replacement status
- Disable stream button for MP4 files (need seeking, can't pipe)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add recoverMediaError() for fatal media errors (up to 2 retries)
- Fall back to direct video URL if HLS recovery fails
- Remove verbose logging of non-fatal errors (caused log seizure)
- Pin hls.js to v1.5.18 instead of @latest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The fmp4 segments with negative_cts_offsets caused bufferAppendError
in hls.js even with H.264 content. Switching to MPEG-TS segments
lets hls.js handle transmuxing internally, which is more reliable.

- Switch from fmp4 to MPEG-TS segment format
- Remove movflags (not applicable to TS)
- Remove fmp4 init filename (not needed for TS)
- Add onStreamError callback: falls back to loading screen on failure
- Suppress non-fatal HLS error spam

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MPEG-TS doesn't support HEVC. Revert to fmp4 segments but only use
default_base_moof (required for MSE) without negative_cts_offsets
which was causing bufferAppendError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add +frag_keyframe to movflags: ensures each fragment starts at a
  keyframe so seeking lands on a decodable frame
- Add -reset_timestamps 1: resets PTS/DTS per segment to prevent
  timestamp drift that causes seek-to-beginning
- Add -fflags +genpts: regenerates presentation timestamps for
  clean timing when reading from pipe
- Remove independent_segments flag: can't guarantee with -c:v copy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the MP4 becomes available, seamlessly switch the player from
HLS to MP4 while preserving the current playback position. MP4
provides better seeking and Chromecast compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wait for loadedmetadata before restoring position (more reliable),
and re-apply the saved caption language preference after the source
switch since browsers reset track modes on src change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The badge was hidden by overflow:hidden on the filename-truncate div
when filenames were long. Now it's a flex sibling with flex-shrink:0
so it's always visible regardless of filename length.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When HLS fails (e.g. HEVC in Chrome), onStreamError clears play_link
but polling would see hls_filename again and re-enter the player,
creating a loop. Added hlsFailed flag to prevent re-entering HLS
after failure — stays on loading screen until MP4 is ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Detect video codec and set -tag:v hvc1 for HEVC or avc1 for H.264.
Without proper tagging, MKV→fmp4 defaults to hev1 which Chrome's
MSE rejects. Also removed movflags (not needed when ffmpeg handles
fmp4 via -hls_segment_type) matching kosmi media-proxy implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2MB was too eager for slow torrents — ffmpeg would start and stall
waiting for data. 10MB provides enough buffer for several segments
before the stream button appears.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10MB still not enough buffer for slow torrents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The button appeared based on progress > 0 (frontend guess) but the
backend needs 50MB. Now the backend returns can_stream: true only
when the threshold is met and the file format supports pipe streaming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ries

- Handle NETWORK_ERROR with startLoad() instead of giving up
- Allow up to 5 recovery attempts across all error types before
  falling back to loading screen
- Only unknown error types trigger immediate fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove all extra flags (-fflags, -movflags, -reset_timestamps, -map,
-threads) that diverged from the proven media-proxy implementation.
Add -hls_init_time and -bsf:a aac_adtstoasc to match. Also improve
HLS error resilience with network error retry and more recovery attempts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_get_available_bytes was using file_progress() which returns total
downloaded bytes, not sequential from start. With out-of-order pieces,
the pipe feeder would read past holes in the file, feeding corrupted
data to ffmpeg and producing segments with missing frames.

Now only uses get_sequential_bytes() (verified contiguous pieces from
file start) and HTTP progress (which writes sequentially).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The browser resets all text track modes during a source switch, which
triggered the change listener to clear the saved captionLanguage from
localStorage. Also fix disabled tracks not loading content when cycled
via the caption button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use Vue :key on the player component so the entire video element is
destroyed and recreated when the play link changes, instead of patching
the source in place. This eliminates subtitle state bugs during the
switch since mounted/destroyed hooks already handle position save/restore
and subtitle preference reapplication cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HLS tracks can report locale codes like "en-US" instead of "en",
breaking preference matching across HLS/MP4 switches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hls.js's SubtitleTrackController polls textTracks and competes with native
<track> state when the manifest declares no EXT-X-MEDIA:TYPE=SUBTITLES
groups, so users could pick a subtitle from the caption button but cues
never rendered.

Generate a master playlist that wraps the existing media playlist and
exposes each available VTT as an EXT-X-MEDIA:TYPE=SUBTITLES track with
its own subtitle media playlist. hls.js then manages subtitles natively.
The frontend tags the HLS play_link with a ?subs=N counter so Vue
remounts the player (and hls.js refetches the regenerated master) when
new VTTs become available, and skips <track> elements for HLS playback
since hls.js owns the text tracks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With subtitles now declared in the HLS master playlist, hls.js's default
CEA closed-caption parser was rendering embedded captions on top of the
selected subtitle track, causing duplicate text on screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a source video has multiple subtitle streams in the same language
(e.g., Latin American + Castilian Spanish), they were emitted with
identical NAME and LANGUAGE in EXT-X-MEDIA. hls.js's
subtitleTrackMatchesTextTrack matches by label+lang, so selecting one
ended up rendering both VTTs at once.

Number duplicates ("ES 1", "ES 2") so each track has a unique label, and
drop the heuristic DEFAULT=YES on the first entry — the frontend's
applyCaptionPreference already drives selection from the user's stored
language.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hauxir and others added 24 commits April 30, 2026 12:31
The function used to set every track to "hidden" before checking for a
matching language. With hls.js attached, its text-track poll picks the
last "hidden" track when nothing is "showing", so the side effect of
hiding non-matching tracks was promoting the first manifest track
(alphabetically the German one) regardless of the user's preference.

Now the function only touches modes when it found a matching track and
no track is already showing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FFmpeg's MP4 muxer hardcodes tkhd width=0/height=0 and tx3g
BoxRecord=(0,0,0,0) for subtitle tracks, leaving Safari with no overlay
area for embedded mov_text. After conversion, walk the MP4 boxes and
patch each subtitle track's tkhd dimensions and tx3g default-text-box
to span the video frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ffmpeg auto-copies legacy 'text' chapter tracks (codec_tag=text) into
the output even with explicit -map. Safari renders chapter labels as
ghost overlays alongside the real mov_text subtitles, with degenerate
positioning. Pass -map_chapters -1 to drop them, and narrow the tkhd
patcher to skip handler 'text' so the protection holds even if a
chapter track slips through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- _download_external_subtitles: invoke ffmpeg via argv list (shell=False)
  so torrent-derived basenames can't break out of the quoted command.
- HLSStreamer.start_stream: split into synchronous reservation + threaded
  _run_stream. The capacity check and active_streams[m3u8] reservation
  now happen under a lock and the boolean result reflects whether a
  stream actually started, so /api/.../stream stops reporting success
  when MAX_PARALLEL_CONVERSIONS is exhausted.
- _pipe_feeder: cap available by on-disk size and sleep on EOF reads so
  the feeder can't busy-loop when the piece estimator runs ahead of
  bytes flushed to the file.
- Drop the non-pipe HLS branch entirely — MP4 won't use HLS, so gate
  start_hls_stream on PIPE_FRIENDLY_EXTENSIONS and remove the unbounded
  wait that could otherwise hold a stream slot forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop unused is_video local in _handle_torrent (introduced earlier in
  this branch, now dead).
- Annotate langs as List[str] in write_hls_master_playlist so the type
  flows through to lang/lang_safe/name.
- Replace implicit f-string concatenation in the EXT-X-MEDIA line with
  explicit + concatenation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- SIM108: collapse http_progress if/else to a ternary in get_file_status.
- SIM105: use contextlib.suppress(OSError) for the on-disk-size cap in
  the pipe feeder.
- B905: pass strict=False to zip(vtt_filenames, langs).
- SIM108: collapse the per-track NAME if/else to a ternary in
  write_hls_master_playlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add HLS_STREAMING flag (default off); sequential_download, get_file_status
  HLS info, and start_hls_stream all gate on it.
- Fix _download_external_subtitles dedup race by performing the test-and-set
  under a Lock in the caller's thread before launching the @threaded body.
- Cache master-playlist signature so identical writes are skipped on each
  status poll; switch _atomic_write to mkstemp so concurrent writers don't
  clobber each other's tmp file.
- Track HLSStreamer.failed_streams so a failed ffmpeg invocation stops
  surfacing can_stream (cleared on torrent removal).
- Split MAX_PARALLEL_HLS_STREAMS from MAX_PARALLEL_CONVERSIONS so the two
  pipelines no longer exceed the intended ffmpeg-process cap.
- Bundle hls.js 1.5.18 locally instead of loading from cdn.jsdelivr.net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Track HLS Popen in active_streams; add stop()/stop_under() so orphan
  ffmpeg + pipe-feeder threads don't outlive remove_files=True on stale
  or completed torrents
- Cap HLS_START_THRESHOLD by file_size//4 so small files aren't locked
  out of streaming by the 50 MB absolute floor
- startStream now honors the backend's started=false response and
  re-shows the play button instead of leaving the user stuck
- Persist failed-stream verdict via on-disk marker so a bad-codec file
  doesn't re-prompt the play button on every daemon restart
- Copy audio when source is already AAC; transcode otherwise
- Remove stderr log on clean HLS finalize
- Strip both . and _ separators when deriving subtitle language

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- HLSStreamer.start_stream: dedupe with `in` check (None placeholder
  no longer slips past the truthy guard).
- Path-traversal guard for the HLS endpoint (`_is_safe_subpath`,
  basename-only m3u8 filename).
- Structured failure reasons from /stream so the UI can explain
  capacity/disabled/not_ready/etc. instead of silently re-showing ▶.
- 60s timeout around the SRT→VTT ffmpeg call so a malformed sub
  can't wedge the subtitle thread.
- 0.5s TTL cache around get_sequential_bytes; cache evicted on
  torrent removal.
- Cap master-playlist cache by evicting entries scoped under a
  removed torrent's output dir.
- Player no longer remounts on subtitle arrival: :key strips the
  `?subs=N` query string, and the player watches `url` to reload
  hls.js in place while preserving currentTime/play state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Stall watchdog: _pipe_feeder kills its slot via self.stop() after
  HLS_STALL_TIMEOUT (default 180s) of no progress. Reuses the existing
  intentional-exit path so no .failed marker is written — torrent may
  recover and the user can retry.
- URL-escape m3u8 URIs in master + sub-playlists so VTT filenames with
  spaces/commas don't produce malformed manifests.
- Hold per-magnet RLock in get_sequential_bytes around handle deref so
  remove_torrent can't invalidate the handle mid-call. Switched the
  cache TTL to time.monotonic.
- Read _stopping under _reservation_lock in _run_stream to remove the
  read race that could mismark an intentionally-stopped stream as failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bound proc.wait() after stdin close (30s → terminate → kill) so a wedged
  ffmpeg can't hold an HLS slot indefinitely; the kill path is marked
  intentional so it isn't recorded as a codec failure.
- Switch _detect_video_codec to ffprobe primary (MediaInfo fallback);
  ffprobe parses partial MKV more reliably at the start-of-stream
  threshold, avoiding wrong hvc1/avc1 tags that break hls.js playback.
- Clear master-playlist cache in the all-READY removal branch (matches
  the stale-removal path).
- Document that HLS_STREAMING is applied at torrent-add time and needs a
  daemon restart to affect existing torrents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…alls

- Validate magnet_hash against the bittorrent infohash alphabet/lengths
  (32/40/64 lowercase alphanumerics) before interpolating it into output
  and download paths in start_hls_stream; ".." and path separators are
  now structurally unrepresentable. Re-anchor the _is_safe_subpath checks
  on settings.OUTPUT_DIR / DOWNLOAD_DIR (the trusted roots) instead of
  the magnet-hash-derived dirs that would otherwise be the traversal
  target.

- Convert _extract_subtitles_as_vtt and _convert_file_to_mp4 to
  argv-based Popen, removing the f-string-into-shell pattern that made
  torrent-supplied filenames an injection vector. ffmpeg stderr is now
  redirected via the stderr kwarg instead of "2>>", and the success
  rename moves from the shell "&&" chain to os.replace in the caller.
Filter VTTs in the per-magnet output dir to those prefixed with the
current video's stem so season packs don't surface every episode's
subs in each episode's master playlist. Drop a redundant isinstance
check and fold an open()/try/finally into a context manager to clear
basedpyright/ruff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tempfile.mkstemp creates files at mode 0600, so the master playlist
and subtitle sub-playlists ended up unreadable by nginx workers
(www-data). The video media playlist and segments are written by
ffmpeg with the default umask and were fine — only the manifests
served via _atomic_write were affected, which is exactly the
endpoint the frontend hits.

Existing on-disk artifacts retain 0600 until rewritten; the
in-process master-playlist cache clears on daemon restart, so a
container restart is required for old streams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a torrent ships a single subtitle file with no language suffix
(e.g. Movie.srt rather than Movie.eng.srt), the SRT→VTT step
fell through to "{stem}.vtt" — no underscore. The frontend's label
parser takes everything after the last "_" as the language code, so
the picker ended up showing the entire filename. Always tag with
_und when no language can be inferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
caption-button read tracks.length from a live TextTrackList, which
Vue 2 can't observe, and only listened to the `change` event — so
new tracks added by hls.js after mount never triggered a re-render.
Mirror the count into a reactive data field and listen to
addtrack/removetrack on the TextTrackList.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most scene releases that bundle a single language-less .srt are
English; "und" reads as broken in the picker. Use "en" so the track
gets a sensible label and matches a saved English caption preference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ffmpeg's HLS muxer auto-maps the input's subtitle stream when none is
explicitly handled, dumping WebVTT segments named "{stem}{N}.vtt"
alongside the video segments — for a 22-min Simpsons episode that
meant 217 junk files in the output dir. We surface subtitles via the
master playlist instead, so disable subtitle output (-sn) entirely.

The MP4-ready API path's subtitle filter only required startswith(stem),
so those phantom files leaked into the player's picker. Route both
paths through _get_vtt_subtitles which anchors on stem + separator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_run_subtitle_download pre-emits "{stem}_{lang}.vtt" so HLS playback
has subs while MP4 conversion runs. After conversion, the same SRTs
are baked into the MP4 and _extract_subtitles_as_vtt writes them out
again as "{stem}.{idx}_{lang}.vtt". Both files pass the API filter,
both become <track> elements, and the browser auto-shows both for
the same srclang — the user sees subs rendered twice (cycling once
hides the redundant track, which is how the bug surfaced).

Drop the pre-emitted files after extraction confirms it produced
output, so an extraction failure doesn't leave the picker empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant