replace stream-record with parallel httpx download for uncached videos

behavior change: clicking an uncached video now starts a full httpx
download in the background alongside mpv streaming. The cached file
is available for copy/paste as soon as the download completes, without
waiting for playback to finish. stream-record machinery removed from
video_player.py (~60 lines); on_image_done detects the streaming case
and updates path references without restarting playback.
This commit is contained in:
pax 2026-04-12 14:31:23 -05:00
parent 445d3c7a0f
commit 0d72b0ec8a
2 changed files with 25 additions and 84 deletions

View File

@ -3,8 +3,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from pathlib import Path
from PySide6.QtCore import Qt, QTimer, Signal, Property, QPoint from PySide6.QtCore import Qt, QTimer, Signal, Property, QPoint
from PySide6.QtGui import QColor, QIcon, QPixmap, QPainter, QPen, QBrush, QPolygon, QPainterPath, QFont from PySide6.QtGui import QColor, QIcon, QPixmap, QPainter, QPen, QBrush, QPolygon, QPainterPath, QFont
@ -330,14 +328,6 @@ class VideoPlayer(QWidget):
# spawn unmuted by default. _ensure_mpv replays this on creation. # spawn unmuted by default. _ensure_mpv replays this on creation.
self._pending_mute: bool = False self._pending_mute: bool = False
# Stream-record state: mpv's stream-record option tees its
# network stream into a .part file that gets promoted to the
# real cache path on clean EOF. Eliminates the parallel httpx
# download that used to race with mpv for the same bytes.
self._stream_record_tmp: Path | None = None
self._stream_record_target: Path | None = None
self._seeked_during_record: bool = False
def _ensure_mpv(self) -> mpvlib.MPV: def _ensure_mpv(self) -> mpvlib.MPV:
"""Set up mpv callbacks on first use. MPV instance is pre-created.""" """Set up mpv callbacks on first use. MPV instance is pre-created."""
if self._mpv is not None: if self._mpv is not None:
@ -421,8 +411,6 @@ class VideoPlayer(QWidget):
def seek_to_ms(self, ms: int) -> None: def seek_to_ms(self, ms: int) -> None:
if self._mpv: if self._mpv:
self._mpv.seek(ms / 1000.0, 'absolute+exact') self._mpv.seek(ms / 1000.0, 'absolute+exact')
if self._stream_record_target is not None:
self._seeked_during_record = True
def play_file(self, path: str, info: str = "") -> None: def play_file(self, path: str, info: str = "") -> None:
"""Play a file from a local path OR a remote http(s) URL. """Play a file from a local path OR a remote http(s) URL.
@ -458,23 +446,11 @@ class VideoPlayer(QWidget):
self._last_video_size = None # reset dedupe so new file fires a fit self._last_video_size = None # reset dedupe so new file fires a fit
self._apply_loop_to_mpv() self._apply_loop_to_mpv()
# Clean up any leftover .part from a previous play_file that
# didn't finish (rapid clicks, popout closed mid-stream, etc).
self._discard_stream_record()
if path.startswith(("http://", "https://")): if path.startswith(("http://", "https://")):
from urllib.parse import urlparse from urllib.parse import urlparse
from ...core.cache import _referer_for, cached_path_for from ...core.cache import _referer_for
referer = _referer_for(urlparse(path)) referer = _referer_for(urlparse(path))
target = cached_path_for(path) m.loadfile(path, "replace", referrer=referer)
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".part")
m.loadfile(path, "replace",
referrer=referer,
stream_record=tmp.as_posix(),
demuxer_max_bytes="150MiB")
self._stream_record_tmp = tmp
self._stream_record_target = target
else: else:
m.loadfile(path) m.loadfile(path)
if self._autoplay: if self._autoplay:
@ -485,7 +461,6 @@ class VideoPlayer(QWidget):
self._poll_timer.start() self._poll_timer.start()
def stop(self) -> None: def stop(self) -> None:
self._discard_stream_record()
self._poll_timer.stop() self._poll_timer.stop()
if self._mpv: if self._mpv:
self._mpv.command('stop') self._mpv.command('stop')
@ -570,8 +545,6 @@ class VideoPlayer(QWidget):
""" """
if self._mpv: if self._mpv:
self._mpv.seek(pos / 1000.0, 'absolute+exact') self._mpv.seek(pos / 1000.0, 'absolute+exact')
if self._stream_record_target is not None:
self._seeked_during_record = True
def _seek_relative(self, ms: int) -> None: def _seek_relative(self, ms: int) -> None:
if self._mpv: if self._mpv:
@ -669,61 +642,12 @@ class VideoPlayer(QWidget):
if not self._eof_pending: if not self._eof_pending:
return return
self._eof_pending = False self._eof_pending = False
self._finalize_stream_record()
if self._loop_state == 1: # Once if self._loop_state == 1: # Once
self.pause() self.pause()
elif self._loop_state == 2: # Next elif self._loop_state == 2: # Next
self.pause() self.pause()
self.play_next.emit() self.play_next.emit()
# -- Stream-record helpers --
def _discard_stream_record(self) -> None:
"""Remove any pending stream-record temp file without promoting."""
tmp = self._stream_record_tmp
self._stream_record_tmp = None
self._stream_record_target = None
self._seeked_during_record = False
if tmp is not None:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def _finalize_stream_record(self) -> None:
"""Promote the stream-record .part file to its final cache path.
Only promotes if: (a) there is a pending stream-record, (b) the
user did not seek during playback (seeking invalidates the file
because mpv may have skipped byte ranges), and (c) the .part
file exists and is non-empty.
"""
tmp = self._stream_record_tmp
target = self._stream_record_target
self._stream_record_tmp = None
self._stream_record_target = None
if tmp is None or target is None:
return
if self._seeked_during_record:
log.debug("Stream-record discarded (seek during playback): %s", tmp.name)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
return
if not tmp.exists() or tmp.stat().st_size == 0:
log.debug("Stream-record .part missing or empty: %s", tmp.name)
return
try:
os.replace(tmp, target)
log.debug("Stream-record promoted: %s -> %s", tmp.name, target.name)
except OSError as e:
log.warning("Stream-record promote failed: %s", e)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
@staticmethod @staticmethod
def _fmt(ms: int) -> str: def _fmt(ms: int) -> str:
s = ms // 1000 s = ms // 1000

View File

@ -133,8 +133,6 @@ class MediaController:
async def _load(): async def _load():
self._prefetch_pause.clear() self._prefetch_pause.clear()
try: try:
if streaming:
return
path = await download_image(post.file_url, progress_callback=_progress) path = await download_image(post.file_url, progress_callback=_progress)
self._app._signals.image_done.emit(str(path), info) self._app._signals.image_done.emit(str(path), info)
except Exception as e: except Exception as e:
@ -154,6 +152,25 @@ class MediaController:
def on_image_done(self, path: str, info: str) -> None: def on_image_done(self, path: str, info: str) -> None:
self._app._dl_progress.hide() self._app._dl_progress.hide()
# If the preview is already streaming this video from URL,
# just update path references so copy/paste works — don't
# restart playback.
current = self._app._preview._current_path
if current and current.startswith(("http://", "https://")):
from ..core.cache import cached_path_for
if Path(path) == cached_path_for(current):
self._app._preview._current_path = path
idx = self._app._grid.selected_index
if 0 <= idx < len(self._app._grid._thumbs):
self._app._grid._thumbs[idx]._cached_path = path
cn = self._app._search_ctrl._cached_names
if cn is not None:
cn.add(Path(path).name)
self._app._status.showMessage(
f"{len(self._app._posts)} results — Loaded"
)
self.auto_evict_cache()
return
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible(): if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
self._app._preview._info_label.setText(info) self._app._preview._info_label.setText(info)
self._app._preview._current_path = path self._app._preview._current_path = path
@ -182,10 +199,10 @@ class MediaController:
else: else:
self._app._preview._video_player.stop() self._app._preview._video_player.stop()
self._app._preview.set_media(url, info) self._app._preview.set_media(url, info)
# Set the expected cache path on the thumbnail so drag-to-copy # Pre-set the expected cache path on the thumbnail immediately.
# works once the stream-record promotes the .part file on EOF. # The parallel httpx download will also set it via on_image_done
# Streaming videos skip on_image_done (which normally sets this), # when it completes, but this makes it available for drag-to-copy
# so without this the thumbnail never gets a _cached_path. # from the moment streaming starts.
from ..core.cache import cached_path_for from ..core.cache import cached_path_for
idx = self._app._grid.selected_index idx = self._app._grid.selected_index
if 0 <= idx < len(self._app._grid._thumbs): if 0 <= idx < len(self._app._grid._thumbs):