wrkmon 1.0.0__py3-none-any.whl
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.
- wrkmon/__init__.py +4 -0
- wrkmon/__main__.py +6 -0
- wrkmon/app.py +568 -0
- wrkmon/cli.py +289 -0
- wrkmon/core/__init__.py +8 -0
- wrkmon/core/cache.py +208 -0
- wrkmon/core/player.py +301 -0
- wrkmon/core/queue.py +264 -0
- wrkmon/core/youtube.py +178 -0
- wrkmon/data/__init__.py +6 -0
- wrkmon/data/database.py +426 -0
- wrkmon/data/migrations.py +134 -0
- wrkmon/data/models.py +144 -0
- wrkmon/ui/__init__.py +5 -0
- wrkmon/ui/components.py +211 -0
- wrkmon/ui/messages.py +89 -0
- wrkmon/ui/screens/__init__.py +8 -0
- wrkmon/ui/screens/history.py +142 -0
- wrkmon/ui/screens/player.py +222 -0
- wrkmon/ui/screens/playlist.py +278 -0
- wrkmon/ui/screens/search.py +165 -0
- wrkmon/ui/theme.py +326 -0
- wrkmon/ui/views/__init__.py +8 -0
- wrkmon/ui/views/history.py +138 -0
- wrkmon/ui/views/playlists.py +259 -0
- wrkmon/ui/views/queue.py +191 -0
- wrkmon/ui/views/search.py +150 -0
- wrkmon/ui/widgets/__init__.py +7 -0
- wrkmon/ui/widgets/header.py +59 -0
- wrkmon/ui/widgets/player_bar.py +115 -0
- wrkmon/ui/widgets/result_item.py +98 -0
- wrkmon/utils/__init__.py +6 -0
- wrkmon/utils/config.py +172 -0
- wrkmon/utils/mpv_installer.py +190 -0
- wrkmon/utils/stealth.py +124 -0
- wrkmon-1.0.0.dist-info/METADATA +193 -0
- wrkmon-1.0.0.dist-info/RECORD +41 -0
- wrkmon-1.0.0.dist-info/WHEEL +5 -0
- wrkmon-1.0.0.dist-info/entry_points.txt +2 -0
- wrkmon-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
- wrkmon-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Player bar widget - persistent playback controls at the bottom."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widgets import Static, ProgressBar
|
|
7
|
+
|
|
8
|
+
from wrkmon.utils.stealth import get_stealth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlayerBar(Static):
|
|
12
|
+
"""Persistent player bar showing current track and playback controls."""
|
|
13
|
+
|
|
14
|
+
# Reactive state
|
|
15
|
+
title = reactive("No process running")
|
|
16
|
+
is_playing = reactive(False)
|
|
17
|
+
position = reactive(0.0)
|
|
18
|
+
duration = reactive(0.0)
|
|
19
|
+
volume = reactive(80)
|
|
20
|
+
status_text = reactive("") # For showing errors/buffering
|
|
21
|
+
|
|
22
|
+
def __init__(self, **kwargs) -> None:
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
self._stealth = get_stealth()
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
with Vertical(id="player-bar-inner"):
|
|
28
|
+
# Now playing row
|
|
29
|
+
with Horizontal(id="now-playing-row"):
|
|
30
|
+
yield Static("NOW", id="now-label", classes="label")
|
|
31
|
+
yield Static(self._get_status_icon(), id="play-status")
|
|
32
|
+
yield Static(self.title, id="track-title")
|
|
33
|
+
|
|
34
|
+
# Progress row
|
|
35
|
+
with Horizontal(id="progress-row"):
|
|
36
|
+
yield Static(self._format_time(0), id="time-current")
|
|
37
|
+
yield ProgressBar(total=100, show_percentage=False, id="progress")
|
|
38
|
+
yield Static(self._format_time(0), id="time-total")
|
|
39
|
+
|
|
40
|
+
# Volume row
|
|
41
|
+
with Horizontal(id="volume-row"):
|
|
42
|
+
yield Static("VOL", id="vol-label", classes="label")
|
|
43
|
+
yield ProgressBar(total=100, show_percentage=False, id="volume")
|
|
44
|
+
yield Static(f"{self.volume}%", id="vol-value")
|
|
45
|
+
|
|
46
|
+
def _get_status_icon(self) -> str:
|
|
47
|
+
return "▶" if self.is_playing else "■"
|
|
48
|
+
|
|
49
|
+
def _format_time(self, seconds: float) -> str:
|
|
50
|
+
return self._stealth.format_duration(seconds)
|
|
51
|
+
|
|
52
|
+
def _format_title(self, title: str) -> str:
|
|
53
|
+
return self._stealth.get_fake_process_name(title)
|
|
54
|
+
|
|
55
|
+
# Watchers for reactive properties
|
|
56
|
+
def watch_title(self, new_title: str) -> None:
|
|
57
|
+
"""Update title display."""
|
|
58
|
+
try:
|
|
59
|
+
display_title = self._format_title(new_title) if new_title else "No process running"
|
|
60
|
+
self.query_one("#track-title", Static).update(display_title)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def watch_is_playing(self) -> None:
|
|
65
|
+
"""Update play/pause icon."""
|
|
66
|
+
try:
|
|
67
|
+
self.query_one("#play-status", Static).update(self._get_status_icon())
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def watch_position(self, new_pos: float) -> None:
|
|
72
|
+
"""Update progress bar and time."""
|
|
73
|
+
try:
|
|
74
|
+
self.query_one("#time-current", Static).update(self._format_time(new_pos))
|
|
75
|
+
if self.duration > 0:
|
|
76
|
+
progress = (new_pos / self.duration) * 100
|
|
77
|
+
self.query_one("#progress", ProgressBar).update(progress=progress)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def watch_duration(self, new_dur: float) -> None:
|
|
82
|
+
"""Update total duration display."""
|
|
83
|
+
try:
|
|
84
|
+
self.query_one("#time-total", Static).update(self._format_time(new_dur))
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def watch_volume(self, new_vol: int) -> None:
|
|
89
|
+
"""Update volume display."""
|
|
90
|
+
try:
|
|
91
|
+
self.query_one("#volume", ProgressBar).update(progress=new_vol)
|
|
92
|
+
self.query_one("#vol-value", Static).update(f"{new_vol}%")
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def update_playback(
|
|
97
|
+
self,
|
|
98
|
+
title: str | None = None,
|
|
99
|
+
is_playing: bool | None = None,
|
|
100
|
+
position: float | None = None,
|
|
101
|
+
duration: float | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Batch update playback state."""
|
|
104
|
+
if title is not None:
|
|
105
|
+
self.title = title
|
|
106
|
+
if is_playing is not None:
|
|
107
|
+
self.is_playing = is_playing
|
|
108
|
+
if position is not None:
|
|
109
|
+
self.position = position
|
|
110
|
+
if duration is not None:
|
|
111
|
+
self.duration = duration
|
|
112
|
+
|
|
113
|
+
def set_volume(self, volume: int) -> None:
|
|
114
|
+
"""Update volume display."""
|
|
115
|
+
self.volume = max(0, min(100, volume))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Result item widget for displaying search results, queue items, etc."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widgets import ListItem, Label
|
|
5
|
+
|
|
6
|
+
from wrkmon.core.youtube import SearchResult
|
|
7
|
+
from wrkmon.utils.stealth import get_stealth
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResultItem(ListItem):
|
|
11
|
+
"""A list item representing a search result or track."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
result: SearchResult,
|
|
16
|
+
index: int,
|
|
17
|
+
status: str = "READY",
|
|
18
|
+
show_duration: bool = True,
|
|
19
|
+
**kwargs,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(**kwargs)
|
|
22
|
+
self.result = result
|
|
23
|
+
self.index = index
|
|
24
|
+
self.status = status
|
|
25
|
+
self.show_duration = show_duration
|
|
26
|
+
self._stealth = get_stealth()
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
process_name = self._stealth.get_fake_process_name(self.result.title)
|
|
30
|
+
pid = self._stealth.get_fake_pid()
|
|
31
|
+
|
|
32
|
+
if self.show_duration:
|
|
33
|
+
duration = self._stealth.format_duration(self.result.duration)
|
|
34
|
+
text = f"{self.index:>2} {process_name:<38} {pid:>5} {duration:>7} {self.status}"
|
|
35
|
+
else:
|
|
36
|
+
text = f"{self.index:>2} {process_name:<42} {pid:>5} {self.status}"
|
|
37
|
+
|
|
38
|
+
yield Label(text, classes="result-text")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class QueueItem(ListItem):
|
|
42
|
+
"""A list item representing a queue entry."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
title: str,
|
|
47
|
+
duration: int,
|
|
48
|
+
index: int,
|
|
49
|
+
is_current: bool = False,
|
|
50
|
+
**kwargs,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(**kwargs)
|
|
53
|
+
self.title = title
|
|
54
|
+
self.duration = duration
|
|
55
|
+
self.index = index
|
|
56
|
+
self.is_current = is_current
|
|
57
|
+
self._stealth = get_stealth()
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
process_name = self._stealth.get_fake_process_name(self.title)
|
|
61
|
+
duration = self._stealth.format_duration(self.duration)
|
|
62
|
+
status = "RUNNING" if self.is_current else "QUEUED"
|
|
63
|
+
marker = "▶" if self.is_current else " "
|
|
64
|
+
|
|
65
|
+
text = f"{marker} {self.index:>2} {process_name:<40} {duration:>7} {status}"
|
|
66
|
+
yield Label(text, classes="queue-text")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HistoryItem(ListItem):
|
|
70
|
+
"""A list item representing a history entry."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
title: str,
|
|
75
|
+
duration: int,
|
|
76
|
+
play_count: int,
|
|
77
|
+
last_played: str,
|
|
78
|
+
index: int,
|
|
79
|
+
video_id: str,
|
|
80
|
+
channel: str,
|
|
81
|
+
**kwargs,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self.title = title
|
|
85
|
+
self.duration = duration
|
|
86
|
+
self.play_count = play_count
|
|
87
|
+
self.last_played = last_played
|
|
88
|
+
self.index = index
|
|
89
|
+
self.video_id = video_id
|
|
90
|
+
self.channel = channel
|
|
91
|
+
self._stealth = get_stealth()
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
process_name = self._stealth.get_fake_process_name(self.title)[:32]
|
|
95
|
+
duration = self._stealth.format_duration(self.duration)
|
|
96
|
+
|
|
97
|
+
text = f"{self.index:>2} {process_name:<34} {duration:>7} {self.play_count:>3}x {self.last_played}"
|
|
98
|
+
yield Label(text, classes="history-text")
|
wrkmon/utils/__init__.py
ADDED
wrkmon/utils/config.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Configuration management for wrkmon."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# Use tomllib for Python 3.11+, fall back to tomli
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
import tomllib
|
|
11
|
+
else:
|
|
12
|
+
try:
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
except ImportError:
|
|
15
|
+
tomllib = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Config:
|
|
19
|
+
"""Manages application configuration."""
|
|
20
|
+
|
|
21
|
+
DEFAULT_CONFIG = {
|
|
22
|
+
"general": {
|
|
23
|
+
"volume": 80,
|
|
24
|
+
"shuffle": False,
|
|
25
|
+
"repeat": False,
|
|
26
|
+
},
|
|
27
|
+
"player": {
|
|
28
|
+
"mpv_path": "mpv",
|
|
29
|
+
"audio_only": True,
|
|
30
|
+
"no_video": True,
|
|
31
|
+
},
|
|
32
|
+
"cache": {
|
|
33
|
+
"url_ttl_hours": 6,
|
|
34
|
+
"max_entries": 1000,
|
|
35
|
+
},
|
|
36
|
+
"ui": {
|
|
37
|
+
"theme": "matrix", # matrix, minimal, hacker
|
|
38
|
+
"show_fake_stats": True,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self._config: dict[str, Any] = {}
|
|
44
|
+
self._config_dir = self._get_config_dir()
|
|
45
|
+
self._config_file = self._config_dir / "config.toml"
|
|
46
|
+
self._data_dir = self._get_data_dir()
|
|
47
|
+
self._ensure_dirs()
|
|
48
|
+
self._load()
|
|
49
|
+
|
|
50
|
+
def _get_config_dir(self) -> Path:
|
|
51
|
+
"""Get the configuration directory path."""
|
|
52
|
+
if sys.platform == "win32":
|
|
53
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
54
|
+
else:
|
|
55
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
56
|
+
return base / "wrkmon"
|
|
57
|
+
|
|
58
|
+
def _get_data_dir(self) -> Path:
|
|
59
|
+
"""Get the data directory path."""
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
62
|
+
else:
|
|
63
|
+
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
64
|
+
return base / "wrkmon"
|
|
65
|
+
|
|
66
|
+
def _ensure_dirs(self) -> None:
|
|
67
|
+
"""Ensure configuration and data directories exist."""
|
|
68
|
+
self._config_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
def _load(self) -> None:
|
|
72
|
+
"""Load configuration from file."""
|
|
73
|
+
self._config = self.DEFAULT_CONFIG.copy()
|
|
74
|
+
|
|
75
|
+
if self._config_file.exists() and tomllib is not None:
|
|
76
|
+
try:
|
|
77
|
+
with open(self._config_file, "rb") as f:
|
|
78
|
+
user_config = tomllib.load(f)
|
|
79
|
+
self._merge_config(user_config)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass # Use defaults on error
|
|
82
|
+
|
|
83
|
+
def _merge_config(self, user_config: dict[str, Any]) -> None:
|
|
84
|
+
"""Merge user config into default config."""
|
|
85
|
+
for section, values in user_config.items():
|
|
86
|
+
if section in self._config and isinstance(values, dict):
|
|
87
|
+
self._config[section].update(values)
|
|
88
|
+
else:
|
|
89
|
+
self._config[section] = values
|
|
90
|
+
|
|
91
|
+
def save(self) -> None:
|
|
92
|
+
"""Save current configuration to file."""
|
|
93
|
+
lines = []
|
|
94
|
+
for section, values in self._config.items():
|
|
95
|
+
lines.append(f"[{section}]")
|
|
96
|
+
for key, value in values.items():
|
|
97
|
+
if isinstance(value, bool):
|
|
98
|
+
lines.append(f"{key} = {str(value).lower()}")
|
|
99
|
+
elif isinstance(value, str):
|
|
100
|
+
lines.append(f'{key} = "{value}"')
|
|
101
|
+
else:
|
|
102
|
+
lines.append(f"{key} = {value}")
|
|
103
|
+
lines.append("")
|
|
104
|
+
|
|
105
|
+
self._config_file.write_text("\n".join(lines))
|
|
106
|
+
|
|
107
|
+
def get(self, section: str, key: str, default: Any = None) -> Any:
|
|
108
|
+
"""Get a configuration value."""
|
|
109
|
+
return self._config.get(section, {}).get(key, default)
|
|
110
|
+
|
|
111
|
+
def set(self, section: str, key: str, value: Any) -> None:
|
|
112
|
+
"""Set a configuration value."""
|
|
113
|
+
if section not in self._config:
|
|
114
|
+
self._config[section] = {}
|
|
115
|
+
self._config[section][key] = value
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def config_dir(self) -> Path:
|
|
119
|
+
"""Get configuration directory path."""
|
|
120
|
+
return self._config_dir
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def data_dir(self) -> Path:
|
|
124
|
+
"""Get data directory path."""
|
|
125
|
+
return self._data_dir
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def database_path(self) -> Path:
|
|
129
|
+
"""Get database file path."""
|
|
130
|
+
return self._data_dir / "wrkmon.db"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def cache_path(self) -> Path:
|
|
134
|
+
"""Get cache file path."""
|
|
135
|
+
return self._data_dir / "cache.db"
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def volume(self) -> int:
|
|
139
|
+
"""Get current volume setting."""
|
|
140
|
+
return self.get("general", "volume", 80)
|
|
141
|
+
|
|
142
|
+
@volume.setter
|
|
143
|
+
def volume(self, value: int) -> None:
|
|
144
|
+
"""Set volume."""
|
|
145
|
+
self.set("general", "volume", max(0, min(100, value)))
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def mpv_path(self) -> str:
|
|
149
|
+
"""Get mpv executable path."""
|
|
150
|
+
from wrkmon.utils.mpv_installer import get_mpv_path
|
|
151
|
+
configured = self.get("player", "mpv_path", "mpv")
|
|
152
|
+
if configured != "mpv":
|
|
153
|
+
return configured
|
|
154
|
+
# Auto-detect mpv location
|
|
155
|
+
return get_mpv_path()
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def url_ttl_hours(self) -> int:
|
|
159
|
+
"""Get URL cache TTL in hours."""
|
|
160
|
+
return self.get("cache", "url_ttl_hours", 6)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Global config instance
|
|
164
|
+
_config: Config | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_config() -> Config:
|
|
168
|
+
"""Get the global config instance."""
|
|
169
|
+
global _config
|
|
170
|
+
if _config is None:
|
|
171
|
+
_config = Config()
|
|
172
|
+
return _config
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Auto-installer for mpv on Windows."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import shutil
|
|
6
|
+
import zipfile
|
|
7
|
+
import tempfile
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# mpv download URL for Windows (64-bit)
|
|
13
|
+
MPV_DOWNLOAD_URL = "https://sourceforge.net/projects/mpv-player-windows/files/64bit/mpv-x86_64-20240121-git-a39f9b6.7z/download"
|
|
14
|
+
MPV_ZIP_URL = "https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20240121/mpv-x86_64-20240121-git-a39f9b6.7z"
|
|
15
|
+
|
|
16
|
+
# Simpler: use a direct zip from a mirror or bundle
|
|
17
|
+
MPV_PORTABLE_URL = "https://sourceforge.net/projects/mpv-player-windows/files/bootstrapper.zip/download"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_mpv_dir() -> Path:
|
|
21
|
+
"""Get the directory where mpv should be installed."""
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
# Install in LocalAppData/wrkmon/mpv
|
|
24
|
+
local_app_data = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
|
25
|
+
return Path(local_app_data) / "wrkmon" / "mpv"
|
|
26
|
+
else:
|
|
27
|
+
# On Unix, mpv should be installed via package manager
|
|
28
|
+
return Path.home() / ".local" / "share" / "wrkmon" / "mpv"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_mpv_executable() -> Path:
|
|
32
|
+
"""Get the path to the mpv executable."""
|
|
33
|
+
mpv_dir = get_mpv_dir()
|
|
34
|
+
if sys.platform == "win32":
|
|
35
|
+
# Check both possible locations
|
|
36
|
+
exe_path = mpv_dir / "mpv.exe"
|
|
37
|
+
if exe_path.exists():
|
|
38
|
+
return exe_path
|
|
39
|
+
# Also check in subdirectory (some extractions create this)
|
|
40
|
+
sub_path = mpv_dir / "mpv" / "mpv.exe"
|
|
41
|
+
if sub_path.exists():
|
|
42
|
+
return sub_path
|
|
43
|
+
return exe_path # Return default even if not exists
|
|
44
|
+
return mpv_dir / "mpv"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_mpv_installed() -> bool:
|
|
48
|
+
"""Check if mpv is available (either in PATH or our local install)."""
|
|
49
|
+
# Check PATH first
|
|
50
|
+
if shutil.which("mpv"):
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
# Check our local install
|
|
54
|
+
mpv_exe = get_mpv_executable()
|
|
55
|
+
return mpv_exe.exists()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_mpv_path() -> str:
|
|
59
|
+
"""Get the path to mpv executable."""
|
|
60
|
+
# Check PATH first
|
|
61
|
+
system_mpv = shutil.which("mpv")
|
|
62
|
+
if system_mpv:
|
|
63
|
+
return system_mpv
|
|
64
|
+
|
|
65
|
+
# Check our local install
|
|
66
|
+
mpv_exe = get_mpv_executable()
|
|
67
|
+
if mpv_exe.exists():
|
|
68
|
+
return str(mpv_exe)
|
|
69
|
+
|
|
70
|
+
return "mpv" # Default, will fail if not installed
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def download_file(url: str, dest: Path, progress_callback=None) -> bool:
|
|
74
|
+
"""Download a file with optional progress callback."""
|
|
75
|
+
try:
|
|
76
|
+
def report_progress(block_num, block_size, total_size):
|
|
77
|
+
if progress_callback and total_size > 0:
|
|
78
|
+
progress = min(100, (block_num * block_size * 100) // total_size)
|
|
79
|
+
progress_callback(progress)
|
|
80
|
+
|
|
81
|
+
urllib.request.urlretrieve(url, dest, reporthook=report_progress)
|
|
82
|
+
return True
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"Download failed: {e}")
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def install_mpv_windows(progress_callback=None) -> bool:
|
|
89
|
+
"""Download and install mpv on Windows."""
|
|
90
|
+
import subprocess
|
|
91
|
+
|
|
92
|
+
mpv_dir = get_mpv_dir()
|
|
93
|
+
mpv_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
# Use winget if available (cleanest option)
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
["winget", "install", "--id", "mpv.net", "-e", "--silent"],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
timeout=300
|
|
101
|
+
)
|
|
102
|
+
if result.returncode == 0:
|
|
103
|
+
return True
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
# Try chocolatey
|
|
108
|
+
try:
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
["choco", "install", "mpv", "-y"],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
timeout=300
|
|
113
|
+
)
|
|
114
|
+
if result.returncode == 0:
|
|
115
|
+
return True
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
# Manual download as fallback
|
|
120
|
+
# Download portable mpv
|
|
121
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
122
|
+
tmp_path = Path(tmp_dir)
|
|
123
|
+
|
|
124
|
+
if progress_callback:
|
|
125
|
+
progress_callback(0, "Downloading mpv...")
|
|
126
|
+
|
|
127
|
+
# Try to download from GitHub releases (more reliable)
|
|
128
|
+
zip_path = tmp_path / "mpv.zip"
|
|
129
|
+
|
|
130
|
+
# Use a known working URL for portable mpv
|
|
131
|
+
urls_to_try = [
|
|
132
|
+
"https://github.com/shinchiro/mpv-winbuild-cmake/releases/latest/download/mpv-x86_64-latest.7z",
|
|
133
|
+
"https://downloads.sourceforge.net/project/mpv-player-windows/64bit-v3/mpv-x86_64-v3-20240114-git-5765e7f.7z",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# For simplicity, let's just tell users to install manually if auto-install fails
|
|
137
|
+
if progress_callback:
|
|
138
|
+
progress_callback(100, "Please install mpv manually: winget install mpv")
|
|
139
|
+
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def install_mpv(progress_callback=None) -> bool:
|
|
144
|
+
"""Install mpv for the current platform."""
|
|
145
|
+
if sys.platform == "win32":
|
|
146
|
+
return install_mpv_windows(progress_callback)
|
|
147
|
+
else:
|
|
148
|
+
# On Unix, tell user to install via package manager
|
|
149
|
+
print("Please install mpv using your package manager:")
|
|
150
|
+
print(" Ubuntu/Debian: sudo apt install mpv")
|
|
151
|
+
print(" Fedora: sudo dnf install mpv")
|
|
152
|
+
print(" Arch: sudo pacman -S mpv")
|
|
153
|
+
print(" macOS: brew install mpv")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ensure_mpv_installed() -> tuple[bool, str]:
|
|
158
|
+
"""
|
|
159
|
+
Ensure mpv is installed, attempting auto-install if needed.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
tuple: (success: bool, mpv_path_or_error: str)
|
|
163
|
+
"""
|
|
164
|
+
if is_mpv_installed():
|
|
165
|
+
return True, get_mpv_path()
|
|
166
|
+
|
|
167
|
+
# Try to install
|
|
168
|
+
print("mpv not found, attempting to install...")
|
|
169
|
+
if install_mpv():
|
|
170
|
+
if is_mpv_installed():
|
|
171
|
+
return True, get_mpv_path()
|
|
172
|
+
|
|
173
|
+
# Installation failed
|
|
174
|
+
if sys.platform == "win32":
|
|
175
|
+
error_msg = (
|
|
176
|
+
"mpv not found! Please install it:\n"
|
|
177
|
+
" Option 1: winget install mpv\n"
|
|
178
|
+
" Option 2: choco install mpv\n"
|
|
179
|
+
" Option 3: Download from https://mpv.io/installation/"
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
error_msg = (
|
|
183
|
+
"mpv not found! Please install it:\n"
|
|
184
|
+
" Ubuntu/Debian: sudo apt install mpv\n"
|
|
185
|
+
" Fedora: sudo dnf install mpv\n"
|
|
186
|
+
" Arch: sudo pacman -S mpv\n"
|
|
187
|
+
" macOS: brew install mpv"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return False, error_msg
|