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,134 @@
1
+ """Database migrations for wrkmon."""
2
+
3
+ import sqlite3
4
+ from typing import Callable
5
+
6
+ # List of migrations in order
7
+ # Each migration is a tuple of (version, description, up_sql)
8
+ MIGRATIONS: list[tuple[int, str, str]] = [
9
+ (
10
+ 1,
11
+ "Initial schema",
12
+ """
13
+ -- Tracks table
14
+ CREATE TABLE IF NOT EXISTS tracks (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ video_id TEXT UNIQUE NOT NULL,
17
+ title TEXT NOT NULL,
18
+ channel TEXT NOT NULL,
19
+ duration INTEGER NOT NULL,
20
+ thumbnail_url TEXT,
21
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
22
+ );
23
+ CREATE INDEX IF NOT EXISTS idx_tracks_video_id ON tracks(video_id);
24
+
25
+ -- Playlists table
26
+ CREATE TABLE IF NOT EXISTS playlists (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ name TEXT UNIQUE NOT NULL,
29
+ description TEXT DEFAULT '',
30
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
31
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
32
+ );
33
+
34
+ -- Playlist tracks junction table
35
+ CREATE TABLE IF NOT EXISTS playlist_tracks (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ playlist_id INTEGER NOT NULL,
38
+ track_id INTEGER NOT NULL,
39
+ position INTEGER NOT NULL,
40
+ added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
41
+ FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
42
+ FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE,
43
+ UNIQUE(playlist_id, track_id)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
46
+
47
+ -- History table
48
+ CREATE TABLE IF NOT EXISTS history (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ track_id INTEGER NOT NULL,
51
+ played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
52
+ play_count INTEGER DEFAULT 1,
53
+ last_position INTEGER DEFAULT 0,
54
+ completed INTEGER DEFAULT 0,
55
+ FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE
56
+ );
57
+ CREATE INDEX IF NOT EXISTS idx_history_played_at ON history(played_at DESC);
58
+ CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id);
59
+
60
+ -- Schema version table
61
+ CREATE TABLE IF NOT EXISTS schema_version (
62
+ version INTEGER PRIMARY KEY,
63
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
64
+ );
65
+ """,
66
+ ),
67
+ ]
68
+
69
+
70
+ class MigrationManager:
71
+ """Manages database migrations."""
72
+
73
+ def __init__(self, conn: sqlite3.Connection):
74
+ self._conn = conn
75
+ self._ensure_version_table()
76
+
77
+ def _ensure_version_table(self) -> None:
78
+ """Ensure schema_version table exists."""
79
+ self._conn.execute("""
80
+ CREATE TABLE IF NOT EXISTS schema_version (
81
+ version INTEGER PRIMARY KEY,
82
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
83
+ )
84
+ """)
85
+ self._conn.commit()
86
+
87
+ def get_current_version(self) -> int:
88
+ """Get the current schema version."""
89
+ cursor = self._conn.execute(
90
+ "SELECT MAX(version) FROM schema_version"
91
+ )
92
+ result = cursor.fetchone()[0]
93
+ return result if result is not None else 0
94
+
95
+ def get_pending_migrations(self) -> list[tuple[int, str, str]]:
96
+ """Get list of pending migrations."""
97
+ current = self.get_current_version()
98
+ return [m for m in MIGRATIONS if m[0] > current]
99
+
100
+ def apply_migration(self, version: int, description: str, sql: str) -> None:
101
+ """Apply a single migration."""
102
+ # Execute migration SQL
103
+ self._conn.executescript(sql)
104
+
105
+ # Record the migration
106
+ self._conn.execute(
107
+ "INSERT INTO schema_version (version) VALUES (?)",
108
+ (version,),
109
+ )
110
+ self._conn.commit()
111
+
112
+ def migrate(self, target_version: int | None = None) -> list[int]:
113
+ """Run pending migrations up to target version. Returns applied versions."""
114
+ applied = []
115
+ pending = self.get_pending_migrations()
116
+
117
+ for version, description, sql in pending:
118
+ if target_version is not None and version > target_version:
119
+ break
120
+
121
+ self.apply_migration(version, description, sql)
122
+ applied.append(version)
123
+
124
+ return applied
125
+
126
+ def needs_migration(self) -> bool:
127
+ """Check if there are pending migrations."""
128
+ return len(self.get_pending_migrations()) > 0
129
+
130
+
131
+ def run_migrations(conn: sqlite3.Connection) -> list[int]:
132
+ """Convenience function to run all pending migrations."""
133
+ manager = MigrationManager(conn)
134
+ return manager.migrate()
wrkmon/data/models.py ADDED
@@ -0,0 +1,144 @@
1
+ """Data models for wrkmon."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class Track:
10
+ """Represents a track/video."""
11
+
12
+ video_id: str
13
+ title: str
14
+ channel: str
15
+ duration: int # seconds
16
+ thumbnail_url: Optional[str] = None
17
+ id: Optional[int] = None
18
+
19
+ @property
20
+ def url(self) -> str:
21
+ """Get YouTube URL."""
22
+ return f"https://www.youtube.com/watch?v={self.video_id}"
23
+
24
+ @property
25
+ def duration_str(self) -> str:
26
+ """Get formatted duration."""
27
+ mins, secs = divmod(self.duration, 60)
28
+ hours, mins = divmod(mins, 60)
29
+ if hours > 0:
30
+ return f"{hours}:{mins:02d}:{secs:02d}"
31
+ return f"{mins}:{secs:02d}"
32
+
33
+ def to_dict(self) -> dict:
34
+ """Convert to dictionary."""
35
+ return {
36
+ "id": self.id,
37
+ "video_id": self.video_id,
38
+ "title": self.title,
39
+ "channel": self.channel,
40
+ "duration": self.duration,
41
+ "thumbnail_url": self.thumbnail_url,
42
+ }
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: dict) -> "Track":
46
+ """Create from dictionary."""
47
+ return cls(
48
+ id=data.get("id"),
49
+ video_id=data["video_id"],
50
+ title=data["title"],
51
+ channel=data["channel"],
52
+ duration=data["duration"],
53
+ thumbnail_url=data.get("thumbnail_url"),
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class Playlist:
59
+ """Represents a playlist."""
60
+
61
+ name: str
62
+ description: str = ""
63
+ tracks: list[Track] = field(default_factory=list)
64
+ created_at: Optional[datetime] = None
65
+ updated_at: Optional[datetime] = None
66
+ id: Optional[int] = None
67
+
68
+ @property
69
+ def track_count(self) -> int:
70
+ """Get number of tracks."""
71
+ return len(self.tracks)
72
+
73
+ @property
74
+ def total_duration(self) -> int:
75
+ """Get total duration in seconds."""
76
+ return sum(t.duration for t in self.tracks)
77
+
78
+ @property
79
+ def total_duration_str(self) -> str:
80
+ """Get formatted total duration."""
81
+ total = self.total_duration
82
+ hours, remainder = divmod(total, 3600)
83
+ mins, secs = divmod(remainder, 60)
84
+ if hours > 0:
85
+ return f"{hours}h {mins}m"
86
+ return f"{mins}m {secs}s"
87
+
88
+ def to_dict(self) -> dict:
89
+ """Convert to dictionary."""
90
+ return {
91
+ "id": self.id,
92
+ "name": self.name,
93
+ "description": self.description,
94
+ "tracks": [t.to_dict() for t in self.tracks],
95
+ "created_at": self.created_at.isoformat() if self.created_at else None,
96
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
97
+ }
98
+
99
+ @classmethod
100
+ def from_dict(cls, data: dict) -> "Playlist":
101
+ """Create from dictionary."""
102
+ return cls(
103
+ id=data.get("id"),
104
+ name=data["name"],
105
+ description=data.get("description", ""),
106
+ tracks=[Track.from_dict(t) for t in data.get("tracks", [])],
107
+ created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
108
+ updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None,
109
+ )
110
+
111
+
112
+ @dataclass
113
+ class HistoryEntry:
114
+ """Represents a play history entry."""
115
+
116
+ track: Track
117
+ played_at: datetime
118
+ play_count: int = 1
119
+ last_position: int = 0 # seconds, for resume
120
+ completed: bool = False
121
+ id: Optional[int] = None
122
+
123
+ def to_dict(self) -> dict:
124
+ """Convert to dictionary."""
125
+ return {
126
+ "id": self.id,
127
+ "track": self.track.to_dict(),
128
+ "played_at": self.played_at.isoformat(),
129
+ "play_count": self.play_count,
130
+ "last_position": self.last_position,
131
+ "completed": self.completed,
132
+ }
133
+
134
+ @classmethod
135
+ def from_dict(cls, data: dict) -> "HistoryEntry":
136
+ """Create from dictionary."""
137
+ return cls(
138
+ id=data.get("id"),
139
+ track=Track.from_dict(data["track"]),
140
+ played_at=datetime.fromisoformat(data["played_at"]),
141
+ play_count=data.get("play_count", 1),
142
+ last_position=data.get("last_position", 0),
143
+ completed=data.get("completed", False),
144
+ )
wrkmon/ui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """UI package for wrkmon TUI."""
2
+
3
+ from wrkmon.ui.theme import APP_CSS, THEMES
4
+
5
+ __all__ = ["APP_CSS", "THEMES"]
@@ -0,0 +1,211 @@
1
+ """Reusable TUI components for wrkmon."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.widgets import Static, Input, ProgressBar, Label
6
+ from textual.reactive import reactive
7
+
8
+ from wrkmon.utils.stealth import get_stealth
9
+
10
+
11
+ class HeaderBar(Static):
12
+ """Header bar showing app name and fake system stats."""
13
+
14
+ cpu = reactive(23)
15
+ mem = reactive(45)
16
+
17
+ def __init__(self, **kwargs):
18
+ super().__init__(**kwargs)
19
+ self._stealth = get_stealth()
20
+
21
+ def compose(self) -> ComposeResult:
22
+ with Horizontal(id="header"):
23
+ yield Static("WRKMON v1.0.0", id="header-title")
24
+ yield Static(self._get_stats_text(), id="header-stats")
25
+
26
+ def _get_stats_text(self) -> str:
27
+ return f"[CPU: {self.cpu}%] [MEM: {self.mem}%]"
28
+
29
+ def update_stats(self) -> None:
30
+ """Update fake system stats."""
31
+ self.cpu = self._stealth.get_fake_cpu()
32
+ self.mem = self._stealth.get_fake_memory()
33
+ stats = self.query_one("#header-stats", Static)
34
+ stats.update(self._get_stats_text())
35
+
36
+
37
+ class SearchBar(Static):
38
+ """Search input bar."""
39
+
40
+ def compose(self) -> ComposeResult:
41
+ with Horizontal(id="search-container"):
42
+ yield Static("> Search: ", classes="search-label")
43
+ yield Input(placeholder="Enter search query...", id="search-input")
44
+
45
+ def focus_input(self) -> None:
46
+ """Focus the search input."""
47
+ self.query_one("#search-input", Input).focus()
48
+
49
+ def clear_input(self) -> None:
50
+ """Clear the search input."""
51
+ self.query_one("#search-input", Input).value = ""
52
+
53
+ @property
54
+ def value(self) -> str:
55
+ """Get current search value."""
56
+ return self.query_one("#search-input", Input).value
57
+
58
+
59
+ class ResultsHeader(Static):
60
+ """Header row for results list (styled like ps/top output)."""
61
+
62
+ def compose(self) -> ComposeResult:
63
+ with Horizontal(id="results-header"):
64
+ yield Static("#", classes="result-index")
65
+ yield Static("Process Name", classes="result-title")
66
+ yield Static("PID", classes="result-pid")
67
+ yield Static("Status", classes="result-status")
68
+
69
+
70
+ class ResultItem(Static):
71
+ """A single result item styled like a process entry."""
72
+
73
+ def __init__(
74
+ self,
75
+ index: int,
76
+ title: str,
77
+ video_id: str,
78
+ status: str = "READY",
79
+ **kwargs,
80
+ ):
81
+ super().__init__(**kwargs)
82
+ self.index = index
83
+ self.title = title
84
+ self.video_id = video_id
85
+ self.status = status
86
+ self._stealth = get_stealth()
87
+
88
+ def compose(self) -> ComposeResult:
89
+ fake_pid = self._stealth.get_fake_pid()
90
+ process_name = self._stealth.get_fake_process_name(self.title)
91
+
92
+ with Horizontal(classes="result-item"):
93
+ yield Static(f"{self.index}", classes="result-index")
94
+ yield Static(process_name[:40], classes="result-title")
95
+ yield Static(str(fake_pid), classes="result-pid")
96
+ status_class = f"status-{self.status.lower()}"
97
+ yield Static(self.status, classes=f"result-status {status_class}")
98
+
99
+
100
+ class PlayerBar(Static):
101
+ """Bottom player bar showing current track and controls."""
102
+
103
+ title = reactive("No process running")
104
+ status = reactive("STOPPED")
105
+ position = reactive(0.0)
106
+ duration = reactive(0.0)
107
+ volume = reactive(80)
108
+
109
+ def __init__(self, **kwargs):
110
+ super().__init__(**kwargs)
111
+ self._stealth = get_stealth()
112
+
113
+ def compose(self) -> ComposeResult:
114
+ with Vertical(id="player-bar"):
115
+ # Now playing line
116
+ with Horizontal(id="now-playing"):
117
+ yield Static("NOW:", id="now-playing-label")
118
+ yield Static(self.title, id="now-playing-title")
119
+ yield Static(self._get_status_icon(), id="now-playing-status")
120
+
121
+ # Progress bar
122
+ with Horizontal(id="progress-container"):
123
+ yield ProgressBar(total=100, show_percentage=False, id="progress-bar")
124
+ yield Static(self._get_time_text(), id="progress-time")
125
+
126
+ # Volume bar
127
+ with Horizontal(id="volume-container"):
128
+ yield Static("VOL:", id="volume-label")
129
+ yield ProgressBar(total=100, show_percentage=False, id="volume-bar")
130
+ yield Static(f"{self.volume}%", id="volume-value")
131
+
132
+ def _get_status_icon(self) -> str:
133
+ icons = {
134
+ "playing": "▶",
135
+ "paused": "⏸",
136
+ "stopped": "⏹",
137
+ "buffering": "⟳",
138
+ }
139
+ return icons.get(self.status.lower(), "⏹")
140
+
141
+ def _get_time_text(self) -> str:
142
+ pos = self._stealth.format_duration(self.position)
143
+ dur = self._stealth.format_duration(self.duration)
144
+ return f"{pos} / {dur}"
145
+
146
+ def update_now_playing(self, title: str, status: str) -> None:
147
+ """Update the now playing display."""
148
+ self.title = title
149
+ self.status = status
150
+ process_name = self._stealth.get_fake_process_name(title)
151
+ self.query_one("#now-playing-title", Static).update(f"{process_name}")
152
+ self.query_one("#now-playing-status", Static).update(self._get_status_icon())
153
+
154
+ def update_progress(self, position: float, duration: float) -> None:
155
+ """Update the progress bar."""
156
+ self.position = position
157
+ self.duration = duration
158
+
159
+ progress_bar = self.query_one("#progress-bar", ProgressBar)
160
+ if duration > 0:
161
+ progress_bar.update(progress=(position / duration) * 100)
162
+ else:
163
+ progress_bar.update(progress=0)
164
+
165
+ self.query_one("#progress-time", Static).update(self._get_time_text())
166
+
167
+ def update_volume(self, volume: int) -> None:
168
+ """Update the volume display."""
169
+ self.volume = volume
170
+ self.query_one("#volume-bar", ProgressBar).update(progress=volume)
171
+ self.query_one("#volume-value", Static).update(f"{volume}%")
172
+
173
+
174
+ class FooterBar(Static):
175
+ """Footer showing keyboard shortcuts."""
176
+
177
+ def __init__(self, hints: list[tuple[str, str]] = None, **kwargs):
178
+ super().__init__(**kwargs)
179
+ self.hints = hints or [
180
+ ("/", "Search"),
181
+ ("Space", "Play/Pause"),
182
+ ("+/-", "Volume"),
183
+ ("n/p", "Next/Prev"),
184
+ ("q", "Queue"),
185
+ ("h", "History"),
186
+ ("Esc", "Back"),
187
+ ("Ctrl+C", "Quit"),
188
+ ]
189
+
190
+ def compose(self) -> ComposeResult:
191
+ with Horizontal(id="footer"):
192
+ for key, action in self.hints:
193
+ yield Static(f"[{key}] {action}", classes="key-hint")
194
+
195
+
196
+ class LoadingIndicator(Static):
197
+ """Loading indicator."""
198
+
199
+ def compose(self) -> ComposeResult:
200
+ yield Static("Loading...", classes="loading")
201
+
202
+
203
+ class EmptyState(Static):
204
+ """Empty state message."""
205
+
206
+ def __init__(self, message: str = "No data", **kwargs):
207
+ super().__init__(**kwargs)
208
+ self.message = message
209
+
210
+ def compose(self) -> ComposeResult:
211
+ yield Static(self.message, classes="empty-state")
wrkmon/ui/messages.py ADDED
@@ -0,0 +1,89 @@
1
+ """Custom Textual messages for wrkmon component communication."""
2
+
3
+ from textual.message import Message
4
+
5
+ from wrkmon.core.youtube import SearchResult
6
+
7
+
8
+ class TrackSelected(Message):
9
+ """Emitted when a track is selected for playback."""
10
+
11
+ def __init__(self, result: SearchResult) -> None:
12
+ self.result = result
13
+ super().__init__()
14
+
15
+
16
+ class TrackQueued(Message):
17
+ """Emitted when a track is added to queue."""
18
+
19
+ def __init__(self, result: SearchResult) -> None:
20
+ self.result = result
21
+ super().__init__()
22
+
23
+
24
+ class PlaybackStateChanged(Message):
25
+ """Emitted when playback state changes."""
26
+
27
+ def __init__(
28
+ self,
29
+ is_playing: bool,
30
+ position: float = 0.0,
31
+ duration: float = 0.0,
32
+ title: str = "",
33
+ ) -> None:
34
+ self.is_playing = is_playing
35
+ self.position = position
36
+ self.duration = duration
37
+ self.title = title
38
+ super().__init__()
39
+
40
+
41
+ class VolumeChanged(Message):
42
+ """Emitted when volume changes."""
43
+
44
+ def __init__(self, volume: int) -> None:
45
+ self.volume = volume
46
+ super().__init__()
47
+
48
+
49
+ class ViewChanged(Message):
50
+ """Emitted when the active view should change."""
51
+
52
+ def __init__(self, view_name: str) -> None:
53
+ self.view_name = view_name
54
+ super().__init__()
55
+
56
+
57
+ class SearchStarted(Message):
58
+ """Emitted when a search begins."""
59
+
60
+ def __init__(self, query: str) -> None:
61
+ self.query = query
62
+ super().__init__()
63
+
64
+
65
+ class SearchCompleted(Message):
66
+ """Emitted when search results are ready."""
67
+
68
+ def __init__(self, results: list[SearchResult], query: str) -> None:
69
+ self.results = results
70
+ self.query = query
71
+ super().__init__()
72
+
73
+
74
+ class QueueUpdated(Message):
75
+ """Emitted when the queue changes."""
76
+
77
+ def __init__(self, queue_length: int, current_index: int) -> None:
78
+ self.queue_length = queue_length
79
+ self.current_index = current_index
80
+ super().__init__()
81
+
82
+
83
+ class StatusMessage(Message):
84
+ """Emitted to show a status message to the user."""
85
+
86
+ def __init__(self, message: str, level: str = "info") -> None:
87
+ self.message = message
88
+ self.level = level # "info", "success", "warning", "error"
89
+ super().__init__()
@@ -0,0 +1,8 @@
1
+ """TUI screens for wrkmon."""
2
+
3
+ from wrkmon.ui.screens.search import SearchScreen
4
+ from wrkmon.ui.screens.player import PlayerScreen
5
+ from wrkmon.ui.screens.playlist import PlaylistScreen
6
+ from wrkmon.ui.screens.history import HistoryScreen
7
+
8
+ __all__ = ["SearchScreen", "PlayerScreen", "PlaylistScreen", "HistoryScreen"]