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.
@@ -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")
@@ -0,0 +1,6 @@
1
+ """Utility modules for wrkmon."""
2
+
3
+ from wrkmon.utils.config import Config
4
+ from wrkmon.utils.stealth import StealthManager
5
+
6
+ __all__ = ["Config", "StealthManager"]
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