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:
parent
445d3c7a0f
commit
0d72b0ec8a
@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Property, QPoint
|
||||
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.
|
||||
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:
|
||||
"""Set up mpv callbacks on first use. MPV instance is pre-created."""
|
||||
if self._mpv is not None:
|
||||
@ -421,8 +411,6 @@ class VideoPlayer(QWidget):
|
||||
def seek_to_ms(self, ms: int) -> None:
|
||||
if self._mpv:
|
||||
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:
|
||||
"""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._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://")):
|
||||
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))
|
||||
target = cached_path_for(path)
|
||||
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
|
||||
m.loadfile(path, "replace", referrer=referer)
|
||||
else:
|
||||
m.loadfile(path)
|
||||
if self._autoplay:
|
||||
@ -485,7 +461,6 @@ class VideoPlayer(QWidget):
|
||||
self._poll_timer.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._discard_stream_record()
|
||||
self._poll_timer.stop()
|
||||
if self._mpv:
|
||||
self._mpv.command('stop')
|
||||
@ -570,8 +545,6 @@ class VideoPlayer(QWidget):
|
||||
"""
|
||||
if self._mpv:
|
||||
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:
|
||||
if self._mpv:
|
||||
@ -669,61 +642,12 @@ class VideoPlayer(QWidget):
|
||||
if not self._eof_pending:
|
||||
return
|
||||
self._eof_pending = False
|
||||
self._finalize_stream_record()
|
||||
if self._loop_state == 1: # Once
|
||||
self.pause()
|
||||
elif self._loop_state == 2: # Next
|
||||
self.pause()
|
||||
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
|
||||
def _fmt(ms: int) -> str:
|
||||
s = ms // 1000
|
||||
|
||||
@ -133,8 +133,6 @@ class MediaController:
|
||||
async def _load():
|
||||
self._prefetch_pause.clear()
|
||||
try:
|
||||
if streaming:
|
||||
return
|
||||
path = await download_image(post.file_url, progress_callback=_progress)
|
||||
self._app._signals.image_done.emit(str(path), info)
|
||||
except Exception as e:
|
||||
@ -154,6 +152,25 @@ class MediaController:
|
||||
|
||||
def on_image_done(self, path: str, info: str) -> None:
|
||||
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():
|
||||
self._app._preview._info_label.setText(info)
|
||||
self._app._preview._current_path = path
|
||||
@ -182,10 +199,10 @@ class MediaController:
|
||||
else:
|
||||
self._app._preview._video_player.stop()
|
||||
self._app._preview.set_media(url, info)
|
||||
# Set the expected cache path on the thumbnail so drag-to-copy
|
||||
# works once the stream-record promotes the .part file on EOF.
|
||||
# Streaming videos skip on_image_done (which normally sets this),
|
||||
# so without this the thumbnail never gets a _cached_path.
|
||||
# Pre-set the expected cache path on the thumbnail immediately.
|
||||
# The parallel httpx download will also set it via on_image_done
|
||||
# when it completes, but this makes it available for drag-to-copy
|
||||
# from the moment streaming starts.
|
||||
from ..core.cache import cached_path_for
|
||||
idx = self._app._grid.selected_index
|
||||
if 0 <= idx < len(self._app._grid._thumbs):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user