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
|
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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user