Add HLS streaming for instant playback while downloading#58
Add HLS streaming for instant playback while downloading#58
Conversation
Review Summary by QodoAdd HLS streaming for instant playback while downloading non-MP4 video files
WalkthroughsDescription• 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) Diagramflowchart 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"]
File Changes1. app/app.py
|
Code Review by Qodo
1.
|
e489ef9 to
408204c
Compare
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>
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>
This reverts commit b55af9d.
This reverts commit b4e087f.
- _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>
Summary
Test plan
🤖 Generated with Claude Code