wrkmon 1.0.1__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 +592 -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 +258 -0
- wrkmon/ui/widgets/__init__.py +7 -0
- wrkmon/ui/widgets/header.py +59 -0
- wrkmon/ui/widgets/player_bar.py +129 -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.1.dist-info/METADATA +166 -0
- wrkmon-1.0.1.dist-info/RECORD +41 -0
- wrkmon-1.0.1.dist-info/WHEEL +5 -0
- wrkmon-1.0.1.dist-info/entry_points.txt +2 -0
- wrkmon-1.0.1.dist-info/licenses/LICENSE +21 -0
- wrkmon-1.0.1.dist-info/top_level.txt +1 -0
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
|
wrkmon/utils/stealth.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Stealth utilities for wrkmon - makes everything look like a dev tool."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import random
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StealthManager:
|
|
10
|
+
"""Manages stealth features for the application."""
|
|
11
|
+
|
|
12
|
+
# Fake process names that look like legitimate dev tools
|
|
13
|
+
FAKE_PROCESS_NAMES = [
|
|
14
|
+
"node-inspector",
|
|
15
|
+
"webpack-dev-srv",
|
|
16
|
+
"vite-hmr-watch",
|
|
17
|
+
"eslint-daemon",
|
|
18
|
+
"tsc-watch",
|
|
19
|
+
"pytest-runner",
|
|
20
|
+
"cargo-watch",
|
|
21
|
+
"go-build-srv",
|
|
22
|
+
"rust-analyzer",
|
|
23
|
+
"prettier-fmt",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Fake CPU/Memory stats ranges for the UI
|
|
27
|
+
CPU_RANGE = (12, 45)
|
|
28
|
+
MEM_RANGE = (35, 65)
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self._original_title: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
def get_pipe_name(self) -> str:
|
|
34
|
+
"""Get the IPC pipe/socket name for mpv."""
|
|
35
|
+
if sys.platform == "win32":
|
|
36
|
+
return r"\\.\pipe\wrkmon-mpv"
|
|
37
|
+
else:
|
|
38
|
+
# Unix socket in runtime dir
|
|
39
|
+
runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
|
40
|
+
return f"{runtime_dir}/wrkmon-mpv.sock"
|
|
41
|
+
|
|
42
|
+
def get_fake_process_name(self, video_title: str) -> str:
|
|
43
|
+
"""Convert a video title to a fake process name."""
|
|
44
|
+
# Sanitize and truncate the title
|
|
45
|
+
name = video_title.lower()
|
|
46
|
+
# Replace spaces and special chars with hyphens
|
|
47
|
+
name = "".join(c if c.isalnum() else "-" for c in name)
|
|
48
|
+
# Remove consecutive hyphens
|
|
49
|
+
while "--" in name:
|
|
50
|
+
name = name.replace("--", "-")
|
|
51
|
+
# Trim and limit length
|
|
52
|
+
name = name.strip("-")[:30]
|
|
53
|
+
return name or "media-process"
|
|
54
|
+
|
|
55
|
+
def get_fake_pid(self) -> int:
|
|
56
|
+
"""Generate a fake PID that looks realistic."""
|
|
57
|
+
return random.randint(1000, 65535)
|
|
58
|
+
|
|
59
|
+
def get_fake_cpu(self) -> int:
|
|
60
|
+
"""Get a fake CPU usage percentage."""
|
|
61
|
+
return random.randint(*self.CPU_RANGE)
|
|
62
|
+
|
|
63
|
+
def get_fake_memory(self) -> int:
|
|
64
|
+
"""Get a fake memory usage percentage."""
|
|
65
|
+
return random.randint(*self.MEM_RANGE)
|
|
66
|
+
|
|
67
|
+
def set_terminal_title(self, title: str = "wrkmon") -> None:
|
|
68
|
+
"""Set the terminal window title."""
|
|
69
|
+
if sys.platform == "win32":
|
|
70
|
+
os.system(f"title {title}")
|
|
71
|
+
else:
|
|
72
|
+
# ANSI escape sequence for setting terminal title
|
|
73
|
+
sys.stdout.write(f"\033]0;{title}\007")
|
|
74
|
+
sys.stdout.flush()
|
|
75
|
+
|
|
76
|
+
def restore_terminal_title(self) -> None:
|
|
77
|
+
"""Restore the original terminal title."""
|
|
78
|
+
if self._original_title:
|
|
79
|
+
self.set_terminal_title(self._original_title)
|
|
80
|
+
|
|
81
|
+
def get_mpv_args(self) -> list[str]:
|
|
82
|
+
"""Get mpv arguments for stealth operation."""
|
|
83
|
+
return [
|
|
84
|
+
"--no-video",
|
|
85
|
+
"--no-terminal",
|
|
86
|
+
"--really-quiet",
|
|
87
|
+
f"--input-ipc-server={self.get_pipe_name()}",
|
|
88
|
+
"--idle=yes",
|
|
89
|
+
"--force-window=no",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
def format_status(self, status: str) -> str:
|
|
93
|
+
"""Format a status string to look like a system status."""
|
|
94
|
+
status_map = {
|
|
95
|
+
"playing": "RUNNING",
|
|
96
|
+
"paused": "SUSPENDED",
|
|
97
|
+
"stopped": "STOPPED",
|
|
98
|
+
"buffering": "LOADING",
|
|
99
|
+
"ready": "READY",
|
|
100
|
+
"error": "FAILED",
|
|
101
|
+
}
|
|
102
|
+
return status_map.get(status.lower(), status.upper())
|
|
103
|
+
|
|
104
|
+
def format_duration(self, seconds: float) -> str:
|
|
105
|
+
"""Format duration in a clean way."""
|
|
106
|
+
if seconds < 0:
|
|
107
|
+
return "--:--"
|
|
108
|
+
mins, secs = divmod(int(seconds), 60)
|
|
109
|
+
hours, mins = divmod(mins, 60)
|
|
110
|
+
if hours > 0:
|
|
111
|
+
return f"{hours}:{mins:02d}:{secs:02d}"
|
|
112
|
+
return f"{mins}:{secs:02d}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Global stealth manager instance
|
|
116
|
+
_stealth: Optional[StealthManager] = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_stealth() -> StealthManager:
|
|
120
|
+
"""Get the global stealth manager instance."""
|
|
121
|
+
global _stealth
|
|
122
|
+
if _stealth is None:
|
|
123
|
+
_stealth = StealthManager()
|
|
124
|
+
return _stealth
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wrkmon
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Stealth TUI YouTube audio player - stream music while looking productive
|
|
5
|
+
Author-email: Umar Khan Yousafzai <umerfarooqkhan325@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube
|
|
8
|
+
Project-URL: Documentation, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube
|
|
10
|
+
Project-URL: Issues, https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube/issues
|
|
11
|
+
Keywords: youtube,audio,player,tui,music,stealth,productivity,terminal
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: textual>=0.50.0
|
|
29
|
+
Requires-Dist: typer>=0.9.0
|
|
30
|
+
Requires-Dist: yt-dlp>=2024.0.0
|
|
31
|
+
Requires-Dist: rich>=13.0.0
|
|
32
|
+
Requires-Dist: pywin32>=306; sys_platform == "win32"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# wrkmon
|
|
39
|
+
|
|
40
|
+
**Terminal-based YouTube Music Player** - Listen to music right from your terminal!
|
|
41
|
+
|
|
42
|
+
A beautiful TUI (Terminal User Interface) for streaming YouTube audio. No browser needed, just your terminal.
|
|
43
|
+
|
|
44
|
+

|
|
45
|
+

|
|
46
|
+

|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- Search and stream YouTube audio
|
|
52
|
+
- Beautiful terminal interface
|
|
53
|
+
- Queue management with shuffle/repeat
|
|
54
|
+
- Play history and playlists
|
|
55
|
+
- Keyboard-driven controls
|
|
56
|
+
- Cross-platform (Windows, macOS, Linux)
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
### pip (Recommended)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install wrkmon
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> **Note:** You also need mpv installed:
|
|
67
|
+
> - Windows: `winget install mpv`
|
|
68
|
+
> - macOS: `brew install mpv`
|
|
69
|
+
> - Linux: `sudo apt install mpv`
|
|
70
|
+
|
|
71
|
+
### Quick Install Scripts
|
|
72
|
+
|
|
73
|
+
**Windows (PowerShell):**
|
|
74
|
+
```powershell
|
|
75
|
+
irm https://raw.githubusercontent.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube/main/install.ps1 | iex
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**macOS / Linux:**
|
|
79
|
+
```bash
|
|
80
|
+
curl -sSL https://raw.githubusercontent.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube/main/install.sh | bash
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Package Managers
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
# Windows (Chocolatey)
|
|
87
|
+
choco install wrkmon
|
|
88
|
+
|
|
89
|
+
# macOS (Homebrew) - coming soon
|
|
90
|
+
brew install wrkmon
|
|
91
|
+
|
|
92
|
+
# Linux (Snap) - coming soon
|
|
93
|
+
sudo snap install wrkmon
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Usage
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
wrkmon # Launch the TUI
|
|
100
|
+
wrkmon search "q" # Quick search
|
|
101
|
+
wrkmon play <id> # Play a video
|
|
102
|
+
wrkmon history # View history
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Keyboard Controls
|
|
106
|
+
|
|
107
|
+
| Key | Action |
|
|
108
|
+
|-----|--------|
|
|
109
|
+
| `F1` | Search view |
|
|
110
|
+
| `F2` | Queue view |
|
|
111
|
+
| `F3` | History view |
|
|
112
|
+
| `F4` | Playlists view |
|
|
113
|
+
| `F5` | Play / Pause |
|
|
114
|
+
| `F6` | Volume down |
|
|
115
|
+
| `F7` | Volume up |
|
|
116
|
+
| `F8` | Next track |
|
|
117
|
+
| `F9` | Stop |
|
|
118
|
+
| `F10` | Add to queue |
|
|
119
|
+
| `/` | Focus search |
|
|
120
|
+
| `Enter` | Play selected |
|
|
121
|
+
| `a` | Add to queue |
|
|
122
|
+
| `Ctrl+C` | Quit |
|
|
123
|
+
|
|
124
|
+
## Screenshots
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
┌─────────────────────────────────────────────────────────┐
|
|
128
|
+
│ wrkmon [Search] │
|
|
129
|
+
├─────────────────────────────────────────────────────────┤
|
|
130
|
+
│ Search: lofi beats │
|
|
131
|
+
├─────────────────────────────────────────────────────────┤
|
|
132
|
+
│ # Title Channel Duration│
|
|
133
|
+
│ 1 Lofi Hip Hop Radio ChilledCow 3:24:15│
|
|
134
|
+
│ 2 Jazz Lofi Beats Lofi Girl 2:45:00│
|
|
135
|
+
│ 3 Study Music Playlist Study 1:30:22│
|
|
136
|
+
├─────────────────────────────────────────────────────────┤
|
|
137
|
+
│ ▶ Now Playing: Lofi Beats advancement █████░░░░░ 1:23:45 │
|
|
138
|
+
│ F1 Search F2 Queue F5 Play/Pause F9 Stop │
|
|
139
|
+
└─────────────────────────────────────────────────────────┘
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Requirements
|
|
143
|
+
|
|
144
|
+
- Python 3.10+
|
|
145
|
+
- mpv media player
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
git clone https://github.com/Umar-Khan-Yousafzai/Wrkmon-TUI-Youtube.git
|
|
151
|
+
cd Wrkmon-TUI-Youtube
|
|
152
|
+
pip install -e ".[dev]"
|
|
153
|
+
pytest
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
159
|
+
|
|
160
|
+
## Author
|
|
161
|
+
|
|
162
|
+
**Umar Khan Yousafzai**
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
*Enjoy your music!*
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
wrkmon/__init__.py,sha256=X5ppzR6k_fMsxNUhNdnCjgV-UQvrY86IWvjaFrDGaqs,107
|
|
2
|
+
wrkmon/__main__.py,sha256=27UFV2ULX5B8OO5b9HjCtTu5k6hdKXnQlCcNVwlYKto,116
|
|
3
|
+
wrkmon/app.py,sha256=kkGTiloPttmJhnV6Yh6Bnn0YkSDWkwtq-eYKnhpMyPw,21346
|
|
4
|
+
wrkmon/cli.py,sha256=2K72I4PrgivPt4OP14XZjYjWNS7mvfTtcKf4YgZ2JZ4,8189
|
|
5
|
+
wrkmon/core/__init__.py,sha256=50AiIHwvm2hVSBc5qkca7_k8taS4IIs_J8i0irz-rBo,269
|
|
6
|
+
wrkmon/core/cache.py,sha256=-3ZH4GPQt1xk8huq50BuCNinTLFbZzRPXhPAEnmWZAs,6599
|
|
7
|
+
wrkmon/core/player.py,sha256=ZeLvffoid8BUXZA9GDHt1YtQhzkmL9jlAhRfXtOc4AQ,9709
|
|
8
|
+
wrkmon/core/queue.py,sha256=WgIkyi8uHRGLeX1mZoI8o10ABRrCSCLFiZRD20wCNzU,8563
|
|
9
|
+
wrkmon/core/youtube.py,sha256=M0IvRErqec0RaVq58z07L45igxpbkqEiavkDQHAXzac,6215
|
|
10
|
+
wrkmon/data/__init__.py,sha256=-geRAYau8OCtGztlSg4gNkmpNC1LEYbH5bI5y-4SYoo,194
|
|
11
|
+
wrkmon/data/database.py,sha256=Ky1QRq5LZewRtWul_4qWJfoXbKbO7Nw--Ll9QKe9PrU,13448
|
|
12
|
+
wrkmon/data/migrations.py,sha256=E2qBzEVrqp50b5WoQ1tB6hAPgLh50ZGq5YGSp06JPfE,4723
|
|
13
|
+
wrkmon/data/models.py,sha256=C2rmdHnFr6BqRVjkWW6oZ2m6pSXrwMlp84l_7qxwj6A,4305
|
|
14
|
+
wrkmon/ui/__init__.py,sha256=fy6EMFEEOA2k4rzEUsWl93hW_11bn1cYT29qVontggc,111
|
|
15
|
+
wrkmon/ui/components.py,sha256=NaPQbDHnBM1LBZL_r5JcXvxEaCxwoizpMxWGFAAJTTs,7117
|
|
16
|
+
wrkmon/ui/messages.py,sha256=0ds2nvaHQ6-2Ok8hfII6XlFkbNeAnCRFa7KldKZ4m8I,2352
|
|
17
|
+
wrkmon/ui/theme.py,sha256=fVEVpokqEX3BmykS0KdndtjgV6NU8ZCYr7LHo0cH0qE,5260
|
|
18
|
+
wrkmon/ui/screens/__init__.py,sha256=0_MX_qc8di-fRCm58b14nIV7X7iMZAJxfLIxYCwJo4I,316
|
|
19
|
+
wrkmon/ui/screens/history.py,sha256=teRm0HhaWgHKXsu57XQRScgYqn_MvK9qA-5IBoWffRE,4831
|
|
20
|
+
wrkmon/ui/screens/player.py,sha256=NPIV1oobtPdBZEHwpSTXUZeV8R1yrW02AVsoLuL3PZI,8046
|
|
21
|
+
wrkmon/ui/screens/playlist.py,sha256=0m9kIUpJP4QHJTTzP_pKBuZS3gUfZZlhsWVy_iHZp4U,9676
|
|
22
|
+
wrkmon/ui/screens/search.py,sha256=lYwTBbzrIW0amln0o9Hs-RtTt2FqHr2QasIVhxq3FKo,5626
|
|
23
|
+
wrkmon/ui/views/__init__.py,sha256=WNHN2SDDk3f1k9VLHdpTbhWotRw_cCHVkR4-OtbfACo,308
|
|
24
|
+
wrkmon/ui/views/history.py,sha256=j9keAwnOMSGoqFKlT21bpxJlhJqKNMfv7X7-eP80sb0,4895
|
|
25
|
+
wrkmon/ui/views/playlists.py,sha256=5yvknFiQ7saaM_t28n-7JhY28lRxN6z3BFspSwXKyKA,9411
|
|
26
|
+
wrkmon/ui/views/queue.py,sha256=_QmSfFrjFNcrNdfetTbojJpCwE3FPR-ed3bO9BsFlIs,6811
|
|
27
|
+
wrkmon/ui/views/search.py,sha256=lAD-M5x0p89cC2cWoFTff_6RTOLBb3maDaVaodY1jTw,9865
|
|
28
|
+
wrkmon/ui/widgets/__init__.py,sha256=MZZLVqMv5cKOXhEJ-XZ3vVbrcLkg1wpFBtiIItv6HXE,250
|
|
29
|
+
wrkmon/ui/widgets/header.py,sha256=vwZ1pRxROsWgXWaqdVne3_phiCfcCKeYEWg1ubrI7w8,1843
|
|
30
|
+
wrkmon/ui/widgets/player_bar.py,sha256=i6EnHlozAG4o7SRQMUGzaRqYk7wcH56FnQU7_4m2uDY,4824
|
|
31
|
+
wrkmon/ui/widgets/result_item.py,sha256=BroroFbMbT3qoH-Up1RJk3mfDwRCxNqodKgd_K1MeKM,3150
|
|
32
|
+
wrkmon/utils/__init__.py,sha256=C1P1hbS96YERgJTw3zGqEayxwr0AFrDhCAuT0WtPO00,162
|
|
33
|
+
wrkmon/utils/config.py,sha256=0v1VO8YFShFJcF5CQhHenKPVr_tm_r6JtaLJy39GRaE,5332
|
|
34
|
+
wrkmon/utils/mpv_installer.py,sha256=HGS8Sq5ZJMCAZk2OTkHDo2ZyqzmXtaHoxR0guEdMstk,6227
|
|
35
|
+
wrkmon/utils/stealth.py,sha256=EmI0-rUYhdV2fIZ2evwdPlSkqFT-Lq6fEX3ZqQH430A,3901
|
|
36
|
+
wrkmon-1.0.1.dist-info/licenses/LICENSE,sha256=0cLcTBgN-yLwY5jhlp8oK1wBxUCyIH7I4vst8RPxelw,1097
|
|
37
|
+
wrkmon-1.0.1.dist-info/METADATA,sha256=yuFgRbGigyOwJpUR_CVyt4Rdp2CxjEEJ3DzE5xGIptc,5555
|
|
38
|
+
wrkmon-1.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
39
|
+
wrkmon-1.0.1.dist-info/entry_points.txt,sha256=sI_CTRHFwhiEHh5BJZtpwiBsfQekpqXcXiT31wcdUZo,42
|
|
40
|
+
wrkmon-1.0.1.dist-info/top_level.txt,sha256=lQvb7Xi0gQQ8R9z1IkR39vTdL-0jxZreD5McnnPFtLs,7
|
|
41
|
+
wrkmon-1.0.1.dist-info/RECORD,,
|