donghua-cli 3.2.0__tar.gz → 3.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/.github/workflows/publish.yml +4 -1
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/CHANGELOG.md +6 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/PKG-INFO +1 -1
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/packaging/aur/PKGBUILD +1 -1
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/packaging/homebrew/donghua-cli.rb +2 -2
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/pyproject.toml +1 -1
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/scripts/release-prep.sh +25 -11
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/__init__.py +1 -1
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/extractor.py +17 -5
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/player.py +19 -7
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/tui.py +33 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/tests/test_extractor.py +13 -0
- donghua_cli-3.2.1/tests/test_player.py +96 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/.github/workflows/ci.yml +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/.gitignore +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/LICENSE +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/README.md +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/banner.png +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/banner.svg +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/banner.txt +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/episode-selection.png +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/episode-selection.svg +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/features.txt +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/playback-controls.png +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/playback-controls.svg +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/social-banner.png +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/assets/social-banner.svg +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/future_ideas.md +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/docs/index.html +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/packaging/desktop/donghua-cli.desktop +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/packaging/desktop/donghua.bat +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/packaging/desktop/install-desktop.sh +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/packaging/pyinstaller/donghua.spec +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/packaging/scoop/donghua-cli.json +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/scripts/install.ps1 +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/scripts/install.sh +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/__main__.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/app.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/cache.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/cli.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/config.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/health.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/library.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/palette.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/scraper.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/sources/__init__.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/sources/animexin.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/sources/base.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/sources/hdonghua.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/sources/lmanime.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/sources/luciferdonghua.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/sources/misterdonghua.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/theme.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/ui.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/src/donghua_cli/utils.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/tests/__init__.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/tests/test_library.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/tests/test_scraper.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/tests/test_sources.py +0 -0
- {donghua_cli-3.2.0 → donghua_cli-3.2.1}/tests/test_utils.py +0 -0
|
@@ -30,7 +30,10 @@ jobs:
|
|
|
30
30
|
env:
|
|
31
31
|
TWINE_USERNAME: __token__
|
|
32
32
|
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
|
33
|
-
|
|
33
|
+
# --skip-existing makes re-runs idempotent: if a tag is re-pushed
|
|
34
|
+
# to recover from a downstream failure, twine treats "file already
|
|
35
|
+
# exists" as success instead of failing the whole pipeline.
|
|
36
|
+
run: twine upload --skip-existing dist/*
|
|
34
37
|
|
|
35
38
|
- name: Bump AUR / Homebrew / Scoop formulas
|
|
36
39
|
# Runs after PyPI upload so the sdist hash matches what users install.
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Donghua CLI will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.2.1] - 2026-06-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **mpv process leak on auto-next.** The TUI kept no handle on the running player, so Next / Replay / auto-next launched a fresh mpv without stopping the previous one — leaking processes, audio, and IPC sockets, and stranding `wait_for_end` threads. The playback screen now holds the active player, stops it before each new playback, tears it down on screen exit, and uses a per-playback generation tag so a superseded worker never fires auto-next. `wait_for_end` binds the process locally so a concurrent `stop()` can't crash its poll loop.
|
|
9
|
+
- **`geo.dailymotion.com` embeds played as HTML and died in <1s.** Canonicalization only handled the legacy `/embed/video/<id>` path; modern aggregators hand over `geo.dailymotion.com/player/<pid>.html?video=<id>` (id in a query param, no `/video/` in the path). Every embed/geo/player form now collapses to the canonical `https://www.dailymotion.com/video/<id>`.
|
|
10
|
+
|
|
5
11
|
## [3.2.0] - 2026-05-21
|
|
6
12
|
|
|
7
13
|
### Added
|
|
@@ -15,7 +15,7 @@ optdepends=(
|
|
|
15
15
|
'ffmpeg: required by yt-dlp for merging streams'
|
|
16
16
|
)
|
|
17
17
|
source=("https://files.pythonhosted.org/packages/source/d/${pkgname}/${pkgname//-/_}-${pkgver}.tar.gz")
|
|
18
|
-
sha256sums=('
|
|
18
|
+
sha256sums=('fc6f2a9d1fb1542ca8f616963fdf18ef49cb363982feece6989aa432f4d0b9c8')
|
|
19
19
|
|
|
20
20
|
build() {
|
|
21
21
|
cd "${pkgname//-/_}-${pkgver}"
|
|
@@ -4,8 +4,8 @@ class DonghuaCli < Formula
|
|
|
4
4
|
desc "Wuxia-themed terminal client for streaming Chinese animation"
|
|
5
5
|
homepage "https://github.com/Thanukamax/donghua-cli"
|
|
6
6
|
# Update URL and sha256 on each release
|
|
7
|
-
url "https://files.pythonhosted.org/packages/
|
|
8
|
-
sha256 "
|
|
7
|
+
url "https://files.pythonhosted.org/packages/21/2d/3ca89831268b8c9348f3c95fee40e5ddd5d3e3584306d60966802bd9d7ee/donghua_cli-3.2.0.tar.gz"
|
|
8
|
+
sha256 "fc6f2a9d1fb1542ca8f616963fdf18ef49cb363982feece6989aa432f4d0b9c8"
|
|
9
9
|
license "MIT"
|
|
10
10
|
|
|
11
11
|
depends_on "python@3.12"
|
|
@@ -12,28 +12,42 @@ set -euo pipefail
|
|
|
12
12
|
cd "$(dirname "$0")/.."
|
|
13
13
|
|
|
14
14
|
VERSION="${1:-$(python3 -c 'import re,sys; print(re.search(r"\"([^\"]+)\"", open("src/donghua_cli/__init__.py").read()).group(1))')}"
|
|
15
|
-
SDIST_URL="https://files.pythonhosted.org/packages/source/d/donghua-cli/donghua_cli-${VERSION}.tar.gz"
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
# PyPI no longer reliably serves files at the legacy /packages/source/d/<pkg>/
|
|
17
|
+
# path from every CDN edge; use the JSON API to discover the canonical sdist
|
|
18
|
+
# URL + sha256 directly. Propagation can lag a few seconds after twine upload.
|
|
19
|
+
echo "Looking up PyPI metadata for v${VERSION}..."
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
JSON_URL="https://pypi.org/pypi/donghua-cli/${VERSION}/json"
|
|
22
|
+
SDIST_URL=""
|
|
23
|
+
SHA=""
|
|
24
|
+
|
|
25
|
+
for i in 1 2 3 4 5 6 7 8; do
|
|
26
|
+
METADATA="$(curl -sf "$JSON_URL" || true)"
|
|
27
|
+
if [[ -n "$METADATA" ]]; then
|
|
28
|
+
read -r SDIST_URL SHA <<<"$(printf '%s' "$METADATA" | python3 -c '
|
|
29
|
+
import json, sys
|
|
30
|
+
d = json.load(sys.stdin)
|
|
31
|
+
for u in d.get("urls", []):
|
|
32
|
+
if u.get("packagetype") == "sdist":
|
|
33
|
+
print(u["url"], u["digests"]["sha256"])
|
|
24
34
|
break
|
|
35
|
+
')"
|
|
36
|
+
if [[ -n "$SDIST_URL" && -n "$SHA" ]]; then
|
|
37
|
+
break
|
|
38
|
+
fi
|
|
25
39
|
fi
|
|
26
40
|
echo " waiting for PyPI (attempt $i)..."
|
|
27
41
|
sleep 5
|
|
28
42
|
done
|
|
29
43
|
|
|
30
|
-
if [[
|
|
31
|
-
echo "ERROR: couldn't
|
|
44
|
+
if [[ -z "$SDIST_URL" || -z "$SHA" ]]; then
|
|
45
|
+
echo "ERROR: couldn't find sdist for donghua-cli ${VERSION} on PyPI after retries." >&2
|
|
32
46
|
exit 1
|
|
33
47
|
fi
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
echo "sha256: $SHA"
|
|
49
|
+
echo " sdist: $SDIST_URL"
|
|
50
|
+
echo " sha256: $SHA"
|
|
37
51
|
|
|
38
52
|
# AUR PKGBUILD
|
|
39
53
|
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
|
|
@@ -70,11 +70,23 @@ def _canonicalize(url: str) -> str:
|
|
|
70
70
|
|
|
71
71
|
url = _normalise_scheme(url)
|
|
72
72
|
|
|
73
|
-
# Dailymotion:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
# Dailymotion: collapse EVERY embed/geo/player form to the canonical
|
|
74
|
+
# https://www.dailymotion.com/video/<id> that yt-dlp's extractor pins. The
|
|
75
|
+
# legacy /embed/video/<id> path is easy, but modern aggregators hand over the
|
|
76
|
+
# geo player form geo.dailymotion.com/player/<pid>.html?video=<id> — there's
|
|
77
|
+
# no /video/ in the path, so the old substring check missed it and mpv tried
|
|
78
|
+
# to demux the player HTML and died in <1s.
|
|
79
|
+
if "dailymotion." in url:
|
|
80
|
+
vid = None
|
|
81
|
+
m = re.search(r"[?&]video=([A-Za-z0-9]+)", url) # player.html?video=<id>
|
|
82
|
+
if m:
|
|
83
|
+
vid = m.group(1)
|
|
84
|
+
else:
|
|
85
|
+
m = re.search(r"dailymotion\.com/(?:embed/)?video/([A-Za-z0-9]+)", url)
|
|
86
|
+
if m:
|
|
87
|
+
vid = m.group(1)
|
|
88
|
+
if vid:
|
|
89
|
+
url = f"https://www.dailymotion.com/video/{vid}"
|
|
78
90
|
|
|
79
91
|
# ok.ru: /videoembed/<id> → /video/<id>
|
|
80
92
|
if "ok.ru/videoembed/" in url:
|
|
@@ -51,15 +51,15 @@ class Player:
|
|
|
51
51
|
user-quit). Returns False if there was no live player to begin with.
|
|
52
52
|
Safe to call repeatedly.
|
|
53
53
|
"""
|
|
54
|
-
|
|
54
|
+
# Bind the process locally: a concurrent stop() (e.g. the user pressed
|
|
55
|
+
# Next before this episode ended) sets self._process = None, which would
|
|
56
|
+
# otherwise NoneType-crash the poll loop here.
|
|
57
|
+
proc = self._process
|
|
58
|
+
if proc is None:
|
|
55
59
|
return False
|
|
56
|
-
while
|
|
60
|
+
while proc.poll() is None:
|
|
57
61
|
time.sleep(poll_interval)
|
|
58
|
-
|
|
59
|
-
try:
|
|
60
|
-
os.unlink(self._ipc_path)
|
|
61
|
-
except OSError:
|
|
62
|
-
pass
|
|
62
|
+
self._cleanup_socket()
|
|
63
63
|
return True
|
|
64
64
|
|
|
65
65
|
def stop(self) -> None:
|
|
@@ -73,6 +73,18 @@ class Player:
|
|
|
73
73
|
except OSError:
|
|
74
74
|
pass
|
|
75
75
|
self._process = None
|
|
76
|
+
# Reap the IPC socket too — without this, every superseded player leaks
|
|
77
|
+
# a stale socket file in tmp until reboot.
|
|
78
|
+
self._cleanup_socket()
|
|
79
|
+
|
|
80
|
+
def _cleanup_socket(self) -> None:
|
|
81
|
+
"""Remove the IPC socket file if it's still on disk. Idempotent."""
|
|
82
|
+
if self._ipc_path and os.path.exists(self._ipc_path):
|
|
83
|
+
try:
|
|
84
|
+
os.unlink(self._ipc_path)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
self._ipc_path = None
|
|
76
88
|
|
|
77
89
|
def is_playing(self) -> bool:
|
|
78
90
|
return self._process is not None and self._process.poll() is None
|
|
@@ -12,6 +12,7 @@ Screens:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import random
|
|
15
|
+
import threading
|
|
15
16
|
from typing import TYPE_CHECKING
|
|
16
17
|
|
|
17
18
|
from textual import on, work
|
|
@@ -35,6 +36,7 @@ from donghua_cli.palette import GLYPH, PALETTE
|
|
|
35
36
|
from donghua_cli.theme import TECHNIQUES, level_for_episode
|
|
36
37
|
|
|
37
38
|
if TYPE_CHECKING:
|
|
39
|
+
from donghua_cli.player import Player
|
|
38
40
|
from donghua_cli.sources.base import Episode, Series
|
|
39
41
|
|
|
40
42
|
# Banner technique is pinned for the lifetime of the app — never reshuffles
|
|
@@ -798,6 +800,12 @@ class PlaybackScreen(Screen):
|
|
|
798
800
|
self._series_urls = dict(series.urls)
|
|
799
801
|
self._stream_url = ""
|
|
800
802
|
self._server_name = ""
|
|
803
|
+
# The currently-live player, so a new playback (Next / Replay / auto-next)
|
|
804
|
+
# can tear the old mpv down instead of leaking it. _play_gen tags each
|
|
805
|
+
# playback; a superseded worker checks it and bows out without advancing.
|
|
806
|
+
self._active_player: "Player | None" = None
|
|
807
|
+
self._play_gen = 0
|
|
808
|
+
self._play_lock = threading.Lock()
|
|
801
809
|
|
|
802
810
|
def compose(self) -> ComposeResult:
|
|
803
811
|
with Container(id="np-outer"):
|
|
@@ -910,7 +918,17 @@ class PlaybackScreen(Screen):
|
|
|
910
918
|
from donghua_cli.player import Player
|
|
911
919
|
from donghua_cli.sources.base import Series
|
|
912
920
|
|
|
921
|
+
# Tear down whatever was playing before starting the next one, and tag
|
|
922
|
+
# this playback. Without this, pressing Next/Replay while mpv is still
|
|
923
|
+
# open spawns a second mpv and strands the old wait_for_end thread.
|
|
913
924
|
player = Player(self._core._quality)
|
|
925
|
+
with self._play_lock:
|
|
926
|
+
previous = self._active_player
|
|
927
|
+
self._play_gen += 1
|
|
928
|
+
gen = self._play_gen
|
|
929
|
+
self._active_player = player
|
|
930
|
+
if previous is not None:
|
|
931
|
+
previous.stop()
|
|
914
932
|
title = f"{self._series_title} - Episode {ep.number}"
|
|
915
933
|
|
|
916
934
|
if player.play(stream_url, title=title):
|
|
@@ -943,6 +961,11 @@ class PlaybackScreen(Screen):
|
|
|
943
961
|
import time as _time
|
|
944
962
|
started_playback = _time.time()
|
|
945
963
|
player.wait_for_end()
|
|
964
|
+
# If a newer playback superseded us (user hit Next, or another
|
|
965
|
+
# auto-next already fired), our stop() woke wait_for_end early —
|
|
966
|
+
# don't advance or report failure on this stale worker.
|
|
967
|
+
if gen != self._play_gen:
|
|
968
|
+
return
|
|
946
969
|
elapsed = _time.time() - started_playback
|
|
947
970
|
if elapsed < 30:
|
|
948
971
|
self.app.call_from_thread(
|
|
@@ -979,6 +1002,16 @@ class PlaybackScreen(Screen):
|
|
|
979
1002
|
self.current_idx += 1
|
|
980
1003
|
self._play_current()
|
|
981
1004
|
|
|
1005
|
+
def on_unmount(self) -> None:
|
|
1006
|
+
# Leaving playback (Quit / back) must kill the live mpv + its socket,
|
|
1007
|
+
# otherwise it keeps playing detached after the TUI screen is gone.
|
|
1008
|
+
with self._play_lock:
|
|
1009
|
+
self._play_gen += 1 # invalidate any in-flight wait_for_end worker
|
|
1010
|
+
player = self._active_player
|
|
1011
|
+
self._active_player = None
|
|
1012
|
+
if player is not None:
|
|
1013
|
+
player.stop()
|
|
1014
|
+
|
|
982
1015
|
def action_next_ep(self) -> None:
|
|
983
1016
|
if self.current_idx < len(self._episodes) - 1:
|
|
984
1017
|
self.current_idx += 1
|
|
@@ -18,6 +18,19 @@ class TestCanonicalize:
|
|
|
18
18
|
url = "https://www.dailymotion.com/video/k3fOeWYJYDH5DVybIIp"
|
|
19
19
|
assert _canonicalize(url) == url
|
|
20
20
|
|
|
21
|
+
def test_dailymotion_geo_player_html_collapses_to_video(self):
|
|
22
|
+
# Modern aggregator embed: no /video/ in the path, id is a query param.
|
|
23
|
+
url = "https://geo.dailymotion.com/player/xabcd.html?video=k5abc123&mute=true"
|
|
24
|
+
assert _canonicalize(url) == "https://www.dailymotion.com/video/k5abc123"
|
|
25
|
+
|
|
26
|
+
def test_dailymotion_geo_embed_video_collapses_to_www(self):
|
|
27
|
+
url = "https://geo.dailymotion.com/embed/video/k9zzz?autoplay=1"
|
|
28
|
+
assert _canonicalize(url) == "https://www.dailymotion.com/video/k9zzz"
|
|
29
|
+
|
|
30
|
+
def test_dailymotion_protocol_relative_geo_player(self):
|
|
31
|
+
url = "//geo.dailymotion.com/player/x1234.html?video=kPqR9"
|
|
32
|
+
assert _canonicalize(url) == "https://www.dailymotion.com/video/kPqR9"
|
|
33
|
+
|
|
21
34
|
def test_ok_ru_embed_becomes_https_video(self):
|
|
22
35
|
assert _canonicalize("//ok.ru/videoembed/6816742902324") == "https://ok.ru/video/6816742902324"
|
|
23
36
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Tests for the Player lifecycle — process + IPC socket teardown.
|
|
2
|
+
|
|
3
|
+
These cover the v3.2.1 auto-next leak fix: a superseded player must release its
|
|
4
|
+
mpv process and IPC socket, and wait_for_end must survive a concurrent stop()
|
|
5
|
+
that nulls the process reference (which is what Next / Replay / auto-next do).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from donghua_cli.player import Player
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _FakeProc:
|
|
17
|
+
"""Stand-in for subprocess.Popen: stays alive for `alive_polls` poll() calls,
|
|
18
|
+
then reports the exit code set by terminate()/kill() (or 0)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, alive_polls: int = 0):
|
|
21
|
+
self._remaining = alive_polls
|
|
22
|
+
self._code: int | None = None
|
|
23
|
+
self.terminated = False
|
|
24
|
+
self.killed = False
|
|
25
|
+
|
|
26
|
+
def poll(self):
|
|
27
|
+
if self._remaining > 0:
|
|
28
|
+
self._remaining -= 1
|
|
29
|
+
return None
|
|
30
|
+
return self._code if self._code is not None else 0
|
|
31
|
+
|
|
32
|
+
def terminate(self):
|
|
33
|
+
self.terminated = True
|
|
34
|
+
self._code = -15
|
|
35
|
+
|
|
36
|
+
def kill(self):
|
|
37
|
+
self.killed = True
|
|
38
|
+
self._code = -9
|
|
39
|
+
|
|
40
|
+
def wait(self, timeout=None):
|
|
41
|
+
return self._code or 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _make_socket_file() -> str:
|
|
45
|
+
fd, path = tempfile.mkstemp(prefix="dhua-test-", suffix=".sock")
|
|
46
|
+
os.close(fd)
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestPlayerTeardown:
|
|
51
|
+
def test_wait_for_end_no_process_returns_false(self):
|
|
52
|
+
assert Player(quality="720").wait_for_end() is False
|
|
53
|
+
|
|
54
|
+
def test_stop_unlinks_ipc_socket_and_clears_state(self):
|
|
55
|
+
p = Player(quality="720")
|
|
56
|
+
sock = _make_socket_file()
|
|
57
|
+
p._ipc_path = sock
|
|
58
|
+
p._process = _FakeProc(alive_polls=1)
|
|
59
|
+
assert os.path.exists(sock)
|
|
60
|
+
p.stop()
|
|
61
|
+
assert not os.path.exists(sock)
|
|
62
|
+
assert p._process is None
|
|
63
|
+
assert p._ipc_path is None
|
|
64
|
+
|
|
65
|
+
def test_stop_is_idempotent_with_no_process(self):
|
|
66
|
+
p = Player(quality="720")
|
|
67
|
+
p.stop() # must not raise
|
|
68
|
+
p.stop()
|
|
69
|
+
|
|
70
|
+
def test_wait_for_end_cleans_socket_on_natural_exit(self):
|
|
71
|
+
p = Player(quality="720")
|
|
72
|
+
sock = _make_socket_file()
|
|
73
|
+
p._ipc_path = sock
|
|
74
|
+
p._process = _FakeProc(alive_polls=0) # already exited
|
|
75
|
+
assert p.wait_for_end(poll_interval=0.01) is True
|
|
76
|
+
assert not os.path.exists(sock)
|
|
77
|
+
|
|
78
|
+
def test_wait_for_end_survives_concurrent_stop(self):
|
|
79
|
+
# The core auto-next race: a newer playback stops this player (nulling
|
|
80
|
+
# self._process) while wait_for_end is mid-poll. Binding the process
|
|
81
|
+
# locally must keep this from NoneType-crashing the poll loop.
|
|
82
|
+
p = Player(quality="720")
|
|
83
|
+
p._ipc_path = _make_socket_file()
|
|
84
|
+
proc = _FakeProc(alive_polls=50)
|
|
85
|
+
p._process = proc
|
|
86
|
+
|
|
87
|
+
def supersede():
|
|
88
|
+
time.sleep(0.02)
|
|
89
|
+
p._process = None # what stop() does to the shared reference
|
|
90
|
+
proc._code = -15 # ...after terminating the underlying process
|
|
91
|
+
proc._remaining = 0
|
|
92
|
+
|
|
93
|
+
t = threading.Thread(target=supersede)
|
|
94
|
+
t.start()
|
|
95
|
+
assert p.wait_for_end(poll_interval=0.01) is True
|
|
96
|
+
t.join()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|