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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Search screen for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.containers import Vertical, ScrollableContainer
|
|
6
|
+
from textual.widgets import Static, Input, ListView, ListItem, Label
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual import on
|
|
9
|
+
|
|
10
|
+
from wrkmon.core.youtube import SearchResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SearchResultItem(ListItem):
|
|
14
|
+
"""A search result list item."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, result: SearchResult, index: int, **kwargs):
|
|
17
|
+
super().__init__(**kwargs)
|
|
18
|
+
self.result = result
|
|
19
|
+
self.index = index
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
from wrkmon.utils.stealth import get_stealth
|
|
23
|
+
stealth = get_stealth()
|
|
24
|
+
|
|
25
|
+
process_name = stealth.get_fake_process_name(self.result.title)[:40]
|
|
26
|
+
fake_pid = stealth.get_fake_pid()
|
|
27
|
+
status = "READY"
|
|
28
|
+
|
|
29
|
+
# Format: index | process name | pid | status
|
|
30
|
+
text = f"{self.index:2} {process_name:<42} {fake_pid:>6} {status}"
|
|
31
|
+
yield Label(text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SearchScreen(Screen):
|
|
35
|
+
"""Main search screen."""
|
|
36
|
+
|
|
37
|
+
BINDINGS = [
|
|
38
|
+
Binding("escape", "clear_or_back", "Clear/Back", show=False),
|
|
39
|
+
Binding("/", "focus_search", "Search", show=False),
|
|
40
|
+
Binding("enter", "play_selected", "Play", show=False),
|
|
41
|
+
Binding("q", "add_to_queue", "Add to Queue", show=False),
|
|
42
|
+
Binding("a", "add_to_playlist", "Add to Playlist", show=False),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
def __init__(self, **kwargs):
|
|
46
|
+
super().__init__(**kwargs)
|
|
47
|
+
self.results: list[SearchResult] = []
|
|
48
|
+
self._searching = False
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
with Vertical(id="search-screen"):
|
|
52
|
+
# Search header
|
|
53
|
+
yield Static("> Search: ", id="search-label")
|
|
54
|
+
yield Input(placeholder="Enter search query...", id="search-input")
|
|
55
|
+
|
|
56
|
+
# Results header (looks like ps output)
|
|
57
|
+
yield Static(
|
|
58
|
+
" # Process Name PID Status",
|
|
59
|
+
id="results-header",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Results list
|
|
63
|
+
yield ListView(id="results-list")
|
|
64
|
+
|
|
65
|
+
# Status message
|
|
66
|
+
yield Static("", id="status-message")
|
|
67
|
+
|
|
68
|
+
def on_mount(self) -> None:
|
|
69
|
+
"""Called when screen is mounted."""
|
|
70
|
+
self.query_one("#search-input", Input).focus()
|
|
71
|
+
|
|
72
|
+
@on(Input.Submitted, "#search-input")
|
|
73
|
+
async def handle_search(self, event: Input.Submitted) -> None:
|
|
74
|
+
"""Handle search submission."""
|
|
75
|
+
query = event.value.strip()
|
|
76
|
+
if not query:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
self._searching = True
|
|
80
|
+
self.update_status("Searching...")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
youtube = self.app.youtube
|
|
84
|
+
self.results = await youtube.search(query, max_results=15)
|
|
85
|
+
self.display_results()
|
|
86
|
+
except Exception as e:
|
|
87
|
+
self.update_status(f"Search failed: {e}")
|
|
88
|
+
finally:
|
|
89
|
+
self._searching = False
|
|
90
|
+
|
|
91
|
+
def display_results(self) -> None:
|
|
92
|
+
"""Display search results."""
|
|
93
|
+
list_view = self.query_one("#results-list", ListView)
|
|
94
|
+
list_view.clear()
|
|
95
|
+
|
|
96
|
+
if not self.results:
|
|
97
|
+
self.update_status("No results found")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
for i, result in enumerate(self.results, 1):
|
|
101
|
+
list_view.append(SearchResultItem(result, i))
|
|
102
|
+
|
|
103
|
+
self.update_status(f"Found {len(self.results)} processes")
|
|
104
|
+
list_view.focus()
|
|
105
|
+
|
|
106
|
+
def update_status(self, message: str) -> None:
|
|
107
|
+
"""Update status message."""
|
|
108
|
+
self.query_one("#status-message", Static).update(message)
|
|
109
|
+
|
|
110
|
+
def action_focus_search(self) -> None:
|
|
111
|
+
"""Focus the search input."""
|
|
112
|
+
self.query_one("#search-input", Input).focus()
|
|
113
|
+
|
|
114
|
+
def action_clear_or_back(self) -> None:
|
|
115
|
+
"""Clear search or go back."""
|
|
116
|
+
search_input = self.query_one("#search-input", Input)
|
|
117
|
+
if search_input.value:
|
|
118
|
+
search_input.value = ""
|
|
119
|
+
search_input.focus()
|
|
120
|
+
else:
|
|
121
|
+
# Could go back to previous screen if we had one
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
async def action_play_selected(self) -> None:
|
|
125
|
+
"""Play the selected result."""
|
|
126
|
+
list_view = self.query_one("#results-list", ListView)
|
|
127
|
+
if list_view.highlighted_child is None:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
item = list_view.highlighted_child
|
|
131
|
+
if isinstance(item, SearchResultItem):
|
|
132
|
+
await self.app.play_track(item.result)
|
|
133
|
+
|
|
134
|
+
async def action_add_to_queue(self) -> None:
|
|
135
|
+
"""Add selected to queue."""
|
|
136
|
+
list_view = self.query_one("#results-list", ListView)
|
|
137
|
+
if list_view.highlighted_child is None:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
item = list_view.highlighted_child
|
|
141
|
+
if isinstance(item, SearchResultItem):
|
|
142
|
+
self.app.add_to_queue(item.result)
|
|
143
|
+
self.update_status(f"Added to queue: {item.result.title[:30]}...")
|
|
144
|
+
|
|
145
|
+
def action_add_to_playlist(self) -> None:
|
|
146
|
+
"""Add selected to a playlist."""
|
|
147
|
+
list_view = self.query_one("#results-list", ListView)
|
|
148
|
+
if list_view.highlighted_child is None:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
item = list_view.highlighted_child
|
|
152
|
+
if isinstance(item, SearchResultItem):
|
|
153
|
+
# TODO: Show playlist selection modal
|
|
154
|
+
self.update_status("Playlist feature coming soon")
|
|
155
|
+
|
|
156
|
+
def get_selected_result(self) -> SearchResult | None:
|
|
157
|
+
"""Get the currently selected search result."""
|
|
158
|
+
list_view = self.query_one("#results-list", ListView)
|
|
159
|
+
if list_view.highlighted_child is None:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
item = list_view.highlighted_child
|
|
163
|
+
if isinstance(item, SearchResultItem):
|
|
164
|
+
return item.result
|
|
165
|
+
return None
|
wrkmon/ui/theme.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Theme and CSS for wrkmon TUI."""
|
|
2
|
+
|
|
3
|
+
# Main application CSS
|
|
4
|
+
APP_CSS = """
|
|
5
|
+
/* ============================================
|
|
6
|
+
WRKMON - Terminal Music Player Theme
|
|
7
|
+
Designed to look like a system monitor
|
|
8
|
+
============================================ */
|
|
9
|
+
|
|
10
|
+
/* Base screen styling */
|
|
11
|
+
Screen {
|
|
12
|
+
background: #0a0a0a;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* ----------------------------------------
|
|
16
|
+
HEADER BAR
|
|
17
|
+
---------------------------------------- */
|
|
18
|
+
HeaderBar {
|
|
19
|
+
dock: top;
|
|
20
|
+
height: 1;
|
|
21
|
+
background: #1a1a1a;
|
|
22
|
+
color: #00ff00;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#header-inner {
|
|
26
|
+
width: 100%;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#app-title {
|
|
30
|
+
width: auto;
|
|
31
|
+
color: #00ff00;
|
|
32
|
+
text-style: bold;
|
|
33
|
+
padding: 0 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#current-view {
|
|
37
|
+
width: 1fr;
|
|
38
|
+
color: #008800;
|
|
39
|
+
padding: 0 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#sys-stats {
|
|
43
|
+
width: auto;
|
|
44
|
+
color: #888888;
|
|
45
|
+
padding: 0 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ----------------------------------------
|
|
49
|
+
PLAYER BAR (Bottom)
|
|
50
|
+
---------------------------------------- */
|
|
51
|
+
PlayerBar {
|
|
52
|
+
dock: bottom;
|
|
53
|
+
height: 5;
|
|
54
|
+
background: #1a1a1a;
|
|
55
|
+
border-top: solid #333333;
|
|
56
|
+
padding: 0 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#player-bar-inner {
|
|
60
|
+
height: 100%;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#now-playing-row {
|
|
64
|
+
height: 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#now-label {
|
|
68
|
+
width: 4;
|
|
69
|
+
color: #888888;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#play-status {
|
|
73
|
+
width: 2;
|
|
74
|
+
color: #00ff00;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#track-title {
|
|
78
|
+
width: 1fr;
|
|
79
|
+
color: #ffffff;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#progress-row {
|
|
83
|
+
height: 1;
|
|
84
|
+
padding: 0 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#time-current, #time-total {
|
|
88
|
+
width: 8;
|
|
89
|
+
color: #888888;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#progress {
|
|
93
|
+
width: 1fr;
|
|
94
|
+
background: #333333;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#progress > .bar--bar {
|
|
98
|
+
color: #00ff00;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#volume-row {
|
|
102
|
+
height: 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#vol-label {
|
|
106
|
+
width: 4;
|
|
107
|
+
color: #888888;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#volume {
|
|
111
|
+
width: 20;
|
|
112
|
+
background: #333333;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#volume > .bar--bar {
|
|
116
|
+
color: #00ffff;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#vol-value {
|
|
120
|
+
width: 5;
|
|
121
|
+
color: #888888;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ----------------------------------------
|
|
125
|
+
CONTENT SWITCHER / MAIN AREA
|
|
126
|
+
---------------------------------------- */
|
|
127
|
+
#content-area {
|
|
128
|
+
height: 1fr;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
ContentSwitcher {
|
|
132
|
+
height: 1fr;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ----------------------------------------
|
|
136
|
+
VIEW CONTAINERS
|
|
137
|
+
---------------------------------------- */
|
|
138
|
+
SearchView, QueueView, HistoryView, PlaylistsView {
|
|
139
|
+
height: 1fr;
|
|
140
|
+
padding: 0 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#view-title {
|
|
144
|
+
height: 1;
|
|
145
|
+
color: #00ff00;
|
|
146
|
+
text-style: bold;
|
|
147
|
+
background: #111111;
|
|
148
|
+
padding: 0 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Search container */
|
|
152
|
+
#search-container {
|
|
153
|
+
height: auto;
|
|
154
|
+
padding: 1 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#search-input {
|
|
158
|
+
width: 100%;
|
|
159
|
+
background: #1a1a1a;
|
|
160
|
+
border: tall #333333;
|
|
161
|
+
color: #ffffff;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#search-input:focus {
|
|
165
|
+
border: tall #00ff00;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* List headers (column titles) */
|
|
169
|
+
#list-header {
|
|
170
|
+
height: 1;
|
|
171
|
+
color: #888888;
|
|
172
|
+
background: #111111;
|
|
173
|
+
text-style: bold;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Result/Queue/History lists */
|
|
177
|
+
#results-list, #queue-list, #history-list, #playlist-list {
|
|
178
|
+
height: 1fr;
|
|
179
|
+
background: #0a0a0a;
|
|
180
|
+
scrollbar-background: #1a1a1a;
|
|
181
|
+
scrollbar-color: #333333;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
ListView > ListItem {
|
|
185
|
+
height: 1;
|
|
186
|
+
padding: 0 1;
|
|
187
|
+
color: #cccccc;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
ListView > ListItem:hover {
|
|
191
|
+
background: #1a1a1a;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
ListView > ListItem.-highlight {
|
|
195
|
+
background: #222222;
|
|
196
|
+
color: #00ff00;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.result-text, .queue-text, .history-text {
|
|
200
|
+
width: 100%;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* Status bar */
|
|
204
|
+
#status-bar {
|
|
205
|
+
dock: bottom;
|
|
206
|
+
height: 1;
|
|
207
|
+
color: #888888;
|
|
208
|
+
background: #111111;
|
|
209
|
+
padding: 0 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ----------------------------------------
|
|
213
|
+
QUEUE VIEW SPECIFICS
|
|
214
|
+
---------------------------------------- */
|
|
215
|
+
#now-playing-section {
|
|
216
|
+
height: auto;
|
|
217
|
+
background: #111111;
|
|
218
|
+
padding: 1;
|
|
219
|
+
margin-bottom: 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#section-header {
|
|
223
|
+
color: #888888;
|
|
224
|
+
text-style: bold;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#current-track {
|
|
228
|
+
color: #00ff00;
|
|
229
|
+
padding: 0 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#playback-progress {
|
|
233
|
+
height: 1;
|
|
234
|
+
padding: 0 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#track-progress {
|
|
238
|
+
width: 1fr;
|
|
239
|
+
background: #333333;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#track-progress > .bar--bar {
|
|
243
|
+
color: #00ff00;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#pos-time, #dur-time {
|
|
247
|
+
width: 8;
|
|
248
|
+
color: #888888;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#mode-indicators {
|
|
252
|
+
height: 1;
|
|
253
|
+
padding: 0 1;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#shuffle-indicator, #repeat-indicator {
|
|
257
|
+
width: auto;
|
|
258
|
+
color: #00ffff;
|
|
259
|
+
padding: 0 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ----------------------------------------
|
|
263
|
+
PLAYLIST INPUT
|
|
264
|
+
---------------------------------------- */
|
|
265
|
+
#new-playlist-input {
|
|
266
|
+
width: 100%;
|
|
267
|
+
background: #1a1a1a;
|
|
268
|
+
border: tall #333333;
|
|
269
|
+
color: #ffffff;
|
|
270
|
+
margin: 1 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#new-playlist-input:focus {
|
|
274
|
+
border: tall #00ff00;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ----------------------------------------
|
|
278
|
+
FOOTER
|
|
279
|
+
---------------------------------------- */
|
|
280
|
+
Footer {
|
|
281
|
+
background: #111111;
|
|
282
|
+
color: #888888;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
Footer > .footer--key {
|
|
286
|
+
color: #00ff00;
|
|
287
|
+
background: #1a1a1a;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
Footer > .footer--description {
|
|
291
|
+
color: #888888;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ----------------------------------------
|
|
295
|
+
LABELS (General)
|
|
296
|
+
---------------------------------------- */
|
|
297
|
+
.label {
|
|
298
|
+
color: #888888;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
# Alternative themes (can be switched via config)
|
|
304
|
+
THEMES = {
|
|
305
|
+
"matrix": {
|
|
306
|
+
"primary": "#00ff00",
|
|
307
|
+
"secondary": "#008800",
|
|
308
|
+
"accent": "#00ffff",
|
|
309
|
+
"background": "#0a0a0a",
|
|
310
|
+
"surface": "#1a1a1a",
|
|
311
|
+
},
|
|
312
|
+
"hacker": {
|
|
313
|
+
"primary": "#00ffff",
|
|
314
|
+
"secondary": "#008888",
|
|
315
|
+
"accent": "#ff00ff",
|
|
316
|
+
"background": "#0a0a0a",
|
|
317
|
+
"surface": "#1a1a1a",
|
|
318
|
+
},
|
|
319
|
+
"minimal": {
|
|
320
|
+
"primary": "#ffffff",
|
|
321
|
+
"secondary": "#888888",
|
|
322
|
+
"accent": "#4444ff",
|
|
323
|
+
"background": "#000000",
|
|
324
|
+
"surface": "#111111",
|
|
325
|
+
},
|
|
326
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""View containers for wrkmon TUI."""
|
|
2
|
+
|
|
3
|
+
from wrkmon.ui.views.search import SearchView
|
|
4
|
+
from wrkmon.ui.views.queue import QueueView
|
|
5
|
+
from wrkmon.ui.views.history import HistoryView
|
|
6
|
+
from wrkmon.ui.views.playlists import PlaylistsView
|
|
7
|
+
|
|
8
|
+
__all__ = ["SearchView", "QueueView", "HistoryView", "PlaylistsView"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""History view container for wrkmon."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual.widgets import Static, ListView
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual import on
|
|
8
|
+
|
|
9
|
+
from wrkmon.core.youtube import SearchResult
|
|
10
|
+
from wrkmon.data.models import HistoryEntry
|
|
11
|
+
from wrkmon.ui.widgets.result_item import HistoryItem
|
|
12
|
+
from wrkmon.ui.messages import TrackSelected, TrackQueued, StatusMessage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HistoryView(Vertical):
|
|
16
|
+
"""History view - shows recently played tracks."""
|
|
17
|
+
|
|
18
|
+
BINDINGS = [
|
|
19
|
+
Binding("a", "queue_selected", "Add to Queue", show=True),
|
|
20
|
+
Binding("c", "clear_history", "Clear All", show=True),
|
|
21
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self, **kwargs) -> None:
|
|
25
|
+
super().__init__(**kwargs)
|
|
26
|
+
self.entries: list[HistoryEntry] = []
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
yield Static("HISTORY", id="view-title")
|
|
30
|
+
|
|
31
|
+
yield Static(
|
|
32
|
+
" # Process Duration Runs Last Run",
|
|
33
|
+
id="list-header",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
yield ListView(id="history-list")
|
|
37
|
+
yield Static("Loading history...", id="status-bar")
|
|
38
|
+
|
|
39
|
+
def on_mount(self) -> None:
|
|
40
|
+
"""Load history on mount."""
|
|
41
|
+
self.load_history()
|
|
42
|
+
|
|
43
|
+
def load_history(self) -> None:
|
|
44
|
+
"""Load play history from database."""
|
|
45
|
+
try:
|
|
46
|
+
db = self.app.database
|
|
47
|
+
self.entries = db.get_history(limit=100)
|
|
48
|
+
self._display_history()
|
|
49
|
+
except Exception as e:
|
|
50
|
+
self._update_status(f"Failed to load history: {e}")
|
|
51
|
+
|
|
52
|
+
def _display_history(self) -> None:
|
|
53
|
+
"""Display history entries."""
|
|
54
|
+
list_view = self.query_one("#history-list", ListView)
|
|
55
|
+
list_view.clear()
|
|
56
|
+
|
|
57
|
+
if not self.entries:
|
|
58
|
+
self._update_status("No history yet")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
for i, entry in enumerate(self.entries, 1):
|
|
62
|
+
track = entry.track
|
|
63
|
+
last_played = entry.played_at.strftime("%m-%d %H:%M")
|
|
64
|
+
|
|
65
|
+
list_view.append(
|
|
66
|
+
HistoryItem(
|
|
67
|
+
title=track.title,
|
|
68
|
+
duration=track.duration,
|
|
69
|
+
play_count=entry.play_count,
|
|
70
|
+
last_played=last_played,
|
|
71
|
+
index=i,
|
|
72
|
+
video_id=track.video_id,
|
|
73
|
+
channel=track.channel,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
total_plays = sum(e.play_count for e in self.entries)
|
|
78
|
+
self._update_status(f"{len(self.entries)} tracks, {total_plays} total plays")
|
|
79
|
+
|
|
80
|
+
def _update_status(self, message: str) -> None:
|
|
81
|
+
"""Update status bar."""
|
|
82
|
+
try:
|
|
83
|
+
self.query_one("#status-bar", Static).update(message)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def _get_selected(self) -> HistoryItem | None:
|
|
88
|
+
"""Get the currently selected history item."""
|
|
89
|
+
list_view = self.query_one("#history-list", ListView)
|
|
90
|
+
if list_view.highlighted_child is None:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
item = list_view.highlighted_child
|
|
94
|
+
if isinstance(item, HistoryItem):
|
|
95
|
+
return item
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def _item_to_result(self, item: HistoryItem) -> SearchResult:
|
|
99
|
+
"""Convert a history item to a SearchResult."""
|
|
100
|
+
return SearchResult(
|
|
101
|
+
video_id=item.video_id,
|
|
102
|
+
title=item.title,
|
|
103
|
+
channel=item.channel,
|
|
104
|
+
duration=item.duration,
|
|
105
|
+
view_count=0,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@on(ListView.Selected, "#history-list")
|
|
109
|
+
def handle_item_selected(self, event: ListView.Selected) -> None:
|
|
110
|
+
"""Handle Enter key on a history item - play it."""
|
|
111
|
+
if isinstance(event.item, HistoryItem):
|
|
112
|
+
result = self._item_to_result(event.item)
|
|
113
|
+
self.post_message(TrackSelected(result))
|
|
114
|
+
self._update_status(f"Playing: {event.item.title[:40]}...")
|
|
115
|
+
|
|
116
|
+
def action_queue_selected(self) -> None:
|
|
117
|
+
"""Add selected track to queue."""
|
|
118
|
+
item = self._get_selected()
|
|
119
|
+
if item:
|
|
120
|
+
result = self._item_to_result(item)
|
|
121
|
+
self.post_message(TrackQueued(result))
|
|
122
|
+
self._update_status(f"Queued: {item.title[:40]}...")
|
|
123
|
+
|
|
124
|
+
def action_clear_history(self) -> None:
|
|
125
|
+
"""Clear all history."""
|
|
126
|
+
try:
|
|
127
|
+
db = self.app.database
|
|
128
|
+
count = db.clear_history()
|
|
129
|
+
self.entries = []
|
|
130
|
+
self._display_history()
|
|
131
|
+
self.post_message(StatusMessage(f"Cleared {count} entries", "info"))
|
|
132
|
+
except Exception as e:
|
|
133
|
+
self.post_message(StatusMessage(f"Error: {e}", "error"))
|
|
134
|
+
|
|
135
|
+
def action_refresh(self) -> None:
|
|
136
|
+
"""Refresh history."""
|
|
137
|
+
self.load_history()
|
|
138
|
+
self.post_message(StatusMessage("History refreshed", "info"))
|