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/core/youtube.py ADDED
@@ -0,0 +1,178 @@
1
+ """YouTube integration using yt-dlp."""
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ import yt_dlp
7
+
8
+
9
+ @dataclass
10
+ class SearchResult:
11
+ """Represents a YouTube search result."""
12
+
13
+ video_id: str
14
+ title: str
15
+ channel: str
16
+ duration: int # seconds
17
+ view_count: int
18
+ thumbnail_url: Optional[str] = None
19
+
20
+ @property
21
+ def url(self) -> str:
22
+ """Get the YouTube URL for this result."""
23
+ return f"https://www.youtube.com/watch?v={self.video_id}"
24
+
25
+ @property
26
+ def duration_str(self) -> str:
27
+ """Get duration as formatted string."""
28
+ total_secs = int(self.duration)
29
+ mins, secs = divmod(total_secs, 60)
30
+ hours, mins = divmod(mins, 60)
31
+ if hours > 0:
32
+ return f"{hours}:{mins:02d}:{secs:02d}"
33
+ return f"{mins}:{secs:02d}"
34
+
35
+
36
+ @dataclass
37
+ class StreamInfo:
38
+ """Represents extracted stream information."""
39
+
40
+ video_id: str
41
+ title: str
42
+ audio_url: str
43
+ duration: int
44
+ channel: str
45
+ thumbnail_url: Optional[str] = None
46
+
47
+
48
+ class YouTubeClient:
49
+ """Client for searching and extracting YouTube content."""
50
+
51
+ def __init__(self):
52
+ self._search_opts = {
53
+ "quiet": True,
54
+ "no_warnings": True,
55
+ "extract_flat": True,
56
+ "default_search": "ytsearch",
57
+ }
58
+ self._extract_opts = {
59
+ "quiet": True,
60
+ "no_warnings": True,
61
+ "format": "bestaudio/best",
62
+ "noplaylist": True,
63
+ }
64
+
65
+ async def search(self, query: str, max_results: int = 10) -> list[SearchResult]:
66
+ """Search YouTube for videos."""
67
+ return await asyncio.to_thread(self._search_sync, query, max_results)
68
+
69
+ def _search_sync(self, query: str, max_results: int) -> list[SearchResult]:
70
+ """Synchronous search implementation."""
71
+ results = []
72
+ opts = {
73
+ **self._search_opts,
74
+ "playlistend": max_results,
75
+ }
76
+
77
+ try:
78
+ with yt_dlp.YoutubeDL(opts) as ydl:
79
+ search_query = f"ytsearch{max_results}:{query}"
80
+ info = ydl.extract_info(search_query, download=False)
81
+
82
+ if info and "entries" in info:
83
+ for entry in info["entries"]:
84
+ if entry is None:
85
+ continue
86
+ result = SearchResult(
87
+ video_id=entry.get("id", ""),
88
+ title=entry.get("title", "Unknown"),
89
+ channel=entry.get("channel", entry.get("uploader", "Unknown")),
90
+ duration=entry.get("duration", 0) or 0,
91
+ view_count=entry.get("view_count", 0) or 0,
92
+ thumbnail_url=entry.get("thumbnail"),
93
+ )
94
+ results.append(result)
95
+ except Exception:
96
+ pass # Return empty results on error
97
+
98
+ return results
99
+
100
+ async def get_stream_url(self, video_id: str) -> Optional[StreamInfo]:
101
+ """Extract the audio stream URL for a video."""
102
+ return await asyncio.to_thread(self._get_stream_sync, video_id)
103
+
104
+ def _get_stream_sync(self, video_id: str) -> Optional[StreamInfo]:
105
+ """Synchronous stream extraction."""
106
+ url = f"https://www.youtube.com/watch?v={video_id}"
107
+
108
+ try:
109
+ with yt_dlp.YoutubeDL(self._extract_opts) as ydl:
110
+ info = ydl.extract_info(url, download=False)
111
+
112
+ if info is None:
113
+ return None
114
+
115
+ # Get the best audio URL
116
+ audio_url = info.get("url")
117
+
118
+ # If formats are available, find best audio
119
+ if not audio_url and "formats" in info:
120
+ formats = info["formats"]
121
+ # Prefer audio-only formats
122
+ audio_formats = [
123
+ f for f in formats if f.get("acodec") != "none" and f.get("vcodec") == "none"
124
+ ]
125
+ if audio_formats:
126
+ # Sort by quality (abr = audio bitrate)
127
+ audio_formats.sort(key=lambda x: x.get("abr", 0) or 0, reverse=True)
128
+ audio_url = audio_formats[0].get("url")
129
+ elif formats:
130
+ # Fallback to any format with audio
131
+ for f in formats:
132
+ if f.get("acodec") != "none":
133
+ audio_url = f.get("url")
134
+ break
135
+
136
+ if not audio_url:
137
+ return None
138
+
139
+ return StreamInfo(
140
+ video_id=video_id,
141
+ title=info.get("title", "Unknown"),
142
+ audio_url=audio_url,
143
+ duration=info.get("duration", 0) or 0,
144
+ channel=info.get("channel", info.get("uploader", "Unknown")),
145
+ thumbnail_url=info.get("thumbnail"),
146
+ )
147
+ except Exception:
148
+ return None
149
+
150
+ async def get_video_info(self, video_id: str) -> Optional[SearchResult]:
151
+ """Get video metadata without extracting stream URL."""
152
+ return await asyncio.to_thread(self._get_info_sync, video_id)
153
+
154
+ def _get_info_sync(self, video_id: str) -> Optional[SearchResult]:
155
+ """Synchronous video info extraction."""
156
+ url = f"https://www.youtube.com/watch?v={video_id}"
157
+ opts = {
158
+ **self._search_opts,
159
+ "extract_flat": False,
160
+ }
161
+
162
+ try:
163
+ with yt_dlp.YoutubeDL(opts) as ydl:
164
+ info = ydl.extract_info(url, download=False)
165
+
166
+ if info is None:
167
+ return None
168
+
169
+ return SearchResult(
170
+ video_id=video_id,
171
+ title=info.get("title", "Unknown"),
172
+ channel=info.get("channel", info.get("uploader", "Unknown")),
173
+ duration=info.get("duration", 0) or 0,
174
+ view_count=info.get("view_count", 0) or 0,
175
+ thumbnail_url=info.get("thumbnail"),
176
+ )
177
+ except Exception:
178
+ return None
@@ -0,0 +1,6 @@
1
+ """Data layer for wrkmon."""
2
+
3
+ from wrkmon.data.database import Database
4
+ from wrkmon.data.models import Track, Playlist, HistoryEntry
5
+
6
+ __all__ = ["Database", "Track", "Playlist", "HistoryEntry"]
@@ -0,0 +1,426 @@
1
+ """Database operations for wrkmon."""
2
+
3
+ import sqlite3
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from wrkmon.data.models import Track, Playlist, HistoryEntry
9
+ from wrkmon.data.migrations import run_migrations
10
+ from wrkmon.utils.config import get_config
11
+
12
+
13
+ class Database:
14
+ """Database access layer for wrkmon."""
15
+
16
+ def __init__(self, db_path: Optional[Path] = None):
17
+ config = get_config()
18
+ self._db_path = db_path or config.database_path
19
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
20
+ self._conn: Optional[sqlite3.Connection] = None
21
+ self._init_db()
22
+
23
+ def _init_db(self) -> None:
24
+ """Initialize database connection and run migrations."""
25
+ self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
26
+ self._conn.row_factory = sqlite3.Row
27
+ self._conn.execute("PRAGMA foreign_keys = ON")
28
+ run_migrations(self._conn)
29
+
30
+ def close(self) -> None:
31
+ """Close database connection."""
32
+ if self._conn:
33
+ self._conn.close()
34
+ self._conn = None
35
+
36
+ # ==================== Track Operations ====================
37
+
38
+ def get_or_create_track(
39
+ self,
40
+ video_id: str,
41
+ title: str,
42
+ channel: str,
43
+ duration: int,
44
+ thumbnail_url: Optional[str] = None,
45
+ ) -> Track:
46
+ """Get existing track or create new one."""
47
+ # Try to get existing
48
+ cursor = self._conn.execute(
49
+ "SELECT * FROM tracks WHERE video_id = ?",
50
+ (video_id,),
51
+ )
52
+ row = cursor.fetchone()
53
+
54
+ if row:
55
+ return Track(
56
+ id=row["id"],
57
+ video_id=row["video_id"],
58
+ title=row["title"],
59
+ channel=row["channel"],
60
+ duration=row["duration"],
61
+ thumbnail_url=row["thumbnail_url"],
62
+ )
63
+
64
+ # Create new
65
+ cursor = self._conn.execute(
66
+ """
67
+ INSERT INTO tracks (video_id, title, channel, duration, thumbnail_url)
68
+ VALUES (?, ?, ?, ?, ?)
69
+ """,
70
+ (video_id, title, channel, duration, thumbnail_url),
71
+ )
72
+ self._conn.commit()
73
+
74
+ return Track(
75
+ id=cursor.lastrowid,
76
+ video_id=video_id,
77
+ title=title,
78
+ channel=channel,
79
+ duration=duration,
80
+ thumbnail_url=thumbnail_url,
81
+ )
82
+
83
+ def get_track_by_video_id(self, video_id: str) -> Optional[Track]:
84
+ """Get a track by video ID."""
85
+ cursor = self._conn.execute(
86
+ "SELECT * FROM tracks WHERE video_id = ?",
87
+ (video_id,),
88
+ )
89
+ row = cursor.fetchone()
90
+
91
+ if row:
92
+ return Track(
93
+ id=row["id"],
94
+ video_id=row["video_id"],
95
+ title=row["title"],
96
+ channel=row["channel"],
97
+ duration=row["duration"],
98
+ thumbnail_url=row["thumbnail_url"],
99
+ )
100
+ return None
101
+
102
+ # ==================== Playlist Operations ====================
103
+
104
+ def create_playlist(self, name: str, description: str = "") -> Playlist:
105
+ """Create a new playlist."""
106
+ now = datetime.now()
107
+ cursor = self._conn.execute(
108
+ """
109
+ INSERT INTO playlists (name, description, created_at, updated_at)
110
+ VALUES (?, ?, ?, ?)
111
+ """,
112
+ (name, description, now, now),
113
+ )
114
+ self._conn.commit()
115
+
116
+ return Playlist(
117
+ id=cursor.lastrowid,
118
+ name=name,
119
+ description=description,
120
+ tracks=[],
121
+ created_at=now,
122
+ updated_at=now,
123
+ )
124
+
125
+ def get_playlist(self, playlist_id: int) -> Optional[Playlist]:
126
+ """Get a playlist by ID with its tracks."""
127
+ cursor = self._conn.execute(
128
+ "SELECT * FROM playlists WHERE id = ?",
129
+ (playlist_id,),
130
+ )
131
+ row = cursor.fetchone()
132
+
133
+ if not row:
134
+ return None
135
+
136
+ # Get tracks
137
+ cursor = self._conn.execute(
138
+ """
139
+ SELECT t.* FROM tracks t
140
+ JOIN playlist_tracks pt ON pt.track_id = t.id
141
+ WHERE pt.playlist_id = ?
142
+ ORDER BY pt.position
143
+ """,
144
+ (playlist_id,),
145
+ )
146
+
147
+ tracks = [
148
+ Track(
149
+ id=r["id"],
150
+ video_id=r["video_id"],
151
+ title=r["title"],
152
+ channel=r["channel"],
153
+ duration=r["duration"],
154
+ thumbnail_url=r["thumbnail_url"],
155
+ )
156
+ for r in cursor.fetchall()
157
+ ]
158
+
159
+ return Playlist(
160
+ id=row["id"],
161
+ name=row["name"],
162
+ description=row["description"],
163
+ tracks=tracks,
164
+ created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else None,
165
+ updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else None,
166
+ )
167
+
168
+ def get_playlist_by_name(self, name: str) -> Optional[Playlist]:
169
+ """Get a playlist by name."""
170
+ cursor = self._conn.execute(
171
+ "SELECT id FROM playlists WHERE name = ?",
172
+ (name,),
173
+ )
174
+ row = cursor.fetchone()
175
+
176
+ if row:
177
+ return self.get_playlist(row["id"])
178
+ return None
179
+
180
+ def get_all_playlists(self) -> list[Playlist]:
181
+ """Get all playlists (without tracks for efficiency)."""
182
+ cursor = self._conn.execute(
183
+ """
184
+ SELECT p.*, COUNT(pt.id) as track_count
185
+ FROM playlists p
186
+ LEFT JOIN playlist_tracks pt ON pt.playlist_id = p.id
187
+ GROUP BY p.id
188
+ ORDER BY p.updated_at DESC
189
+ """
190
+ )
191
+
192
+ playlists = []
193
+ for row in cursor.fetchall():
194
+ playlists.append(
195
+ Playlist(
196
+ id=row["id"],
197
+ name=row["name"],
198
+ description=row["description"],
199
+ tracks=[], # Not loaded for efficiency
200
+ created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else None,
201
+ updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else None,
202
+ )
203
+ )
204
+
205
+ return playlists
206
+
207
+ def update_playlist(self, playlist_id: int, name: str = None, description: str = None) -> bool:
208
+ """Update playlist metadata."""
209
+ updates = []
210
+ params = []
211
+
212
+ if name is not None:
213
+ updates.append("name = ?")
214
+ params.append(name)
215
+ if description is not None:
216
+ updates.append("description = ?")
217
+ params.append(description)
218
+
219
+ if not updates:
220
+ return False
221
+
222
+ updates.append("updated_at = ?")
223
+ params.append(datetime.now())
224
+ params.append(playlist_id)
225
+
226
+ cursor = self._conn.execute(
227
+ f"UPDATE playlists SET {', '.join(updates)} WHERE id = ?",
228
+ params,
229
+ )
230
+ self._conn.commit()
231
+
232
+ return cursor.rowcount > 0
233
+
234
+ def delete_playlist(self, playlist_id: int) -> bool:
235
+ """Delete a playlist."""
236
+ cursor = self._conn.execute(
237
+ "DELETE FROM playlists WHERE id = ?",
238
+ (playlist_id,),
239
+ )
240
+ self._conn.commit()
241
+ return cursor.rowcount > 0
242
+
243
+ def add_track_to_playlist(self, playlist_id: int, track: Track) -> bool:
244
+ """Add a track to a playlist."""
245
+ # Get current max position
246
+ cursor = self._conn.execute(
247
+ "SELECT MAX(position) FROM playlist_tracks WHERE playlist_id = ?",
248
+ (playlist_id,),
249
+ )
250
+ max_pos = cursor.fetchone()[0] or 0
251
+
252
+ try:
253
+ self._conn.execute(
254
+ """
255
+ INSERT INTO playlist_tracks (playlist_id, track_id, position)
256
+ VALUES (?, ?, ?)
257
+ """,
258
+ (playlist_id, track.id, max_pos + 1),
259
+ )
260
+ self._conn.execute(
261
+ "UPDATE playlists SET updated_at = ? WHERE id = ?",
262
+ (datetime.now(), playlist_id),
263
+ )
264
+ self._conn.commit()
265
+ return True
266
+ except sqlite3.IntegrityError:
267
+ return False # Already exists
268
+
269
+ def remove_track_from_playlist(self, playlist_id: int, track_id: int) -> bool:
270
+ """Remove a track from a playlist."""
271
+ cursor = self._conn.execute(
272
+ """
273
+ DELETE FROM playlist_tracks
274
+ WHERE playlist_id = ? AND track_id = ?
275
+ """,
276
+ (playlist_id, track_id),
277
+ )
278
+ if cursor.rowcount > 0:
279
+ self._conn.execute(
280
+ "UPDATE playlists SET updated_at = ? WHERE id = ?",
281
+ (datetime.now(), playlist_id),
282
+ )
283
+ self._conn.commit()
284
+ return True
285
+ return False
286
+
287
+ # ==================== History Operations ====================
288
+
289
+ def add_to_history(self, track: Track) -> HistoryEntry:
290
+ """Add or update a history entry for a track."""
291
+ now = datetime.now()
292
+
293
+ # Check if already in history
294
+ cursor = self._conn.execute(
295
+ "SELECT * FROM history WHERE track_id = ?",
296
+ (track.id,),
297
+ )
298
+ row = cursor.fetchone()
299
+
300
+ if row:
301
+ # Update existing
302
+ self._conn.execute(
303
+ """
304
+ UPDATE history
305
+ SET played_at = ?, play_count = play_count + 1
306
+ WHERE id = ?
307
+ """,
308
+ (now, row["id"]),
309
+ )
310
+ self._conn.commit()
311
+
312
+ return HistoryEntry(
313
+ id=row["id"],
314
+ track=track,
315
+ played_at=now,
316
+ play_count=row["play_count"] + 1,
317
+ last_position=row["last_position"],
318
+ completed=bool(row["completed"]),
319
+ )
320
+ else:
321
+ # Create new
322
+ cursor = self._conn.execute(
323
+ """
324
+ INSERT INTO history (track_id, played_at)
325
+ VALUES (?, ?)
326
+ """,
327
+ (track.id, now),
328
+ )
329
+ self._conn.commit()
330
+
331
+ return HistoryEntry(
332
+ id=cursor.lastrowid,
333
+ track=track,
334
+ played_at=now,
335
+ play_count=1,
336
+ )
337
+
338
+ def update_history_position(self, track_id: int, position: int, completed: bool = False) -> None:
339
+ """Update the last position for a track in history."""
340
+ self._conn.execute(
341
+ """
342
+ UPDATE history
343
+ SET last_position = ?, completed = ?
344
+ WHERE track_id = ?
345
+ """,
346
+ (position, int(completed), track_id),
347
+ )
348
+ self._conn.commit()
349
+
350
+ def get_history(self, limit: int = 50, offset: int = 0) -> list[HistoryEntry]:
351
+ """Get play history."""
352
+ cursor = self._conn.execute(
353
+ """
354
+ SELECT h.*, t.video_id, t.title, t.channel, t.duration, t.thumbnail_url
355
+ FROM history h
356
+ JOIN tracks t ON t.id = h.track_id
357
+ ORDER BY h.played_at DESC
358
+ LIMIT ? OFFSET ?
359
+ """,
360
+ (limit, offset),
361
+ )
362
+
363
+ entries = []
364
+ for row in cursor.fetchall():
365
+ track = Track(
366
+ id=row["track_id"],
367
+ video_id=row["video_id"],
368
+ title=row["title"],
369
+ channel=row["channel"],
370
+ duration=row["duration"],
371
+ thumbnail_url=row["thumbnail_url"],
372
+ )
373
+ entries.append(
374
+ HistoryEntry(
375
+ id=row["id"],
376
+ track=track,
377
+ played_at=datetime.fromisoformat(row["played_at"]),
378
+ play_count=row["play_count"],
379
+ last_position=row["last_position"],
380
+ completed=bool(row["completed"]),
381
+ )
382
+ )
383
+
384
+ return entries
385
+
386
+ def clear_history(self) -> int:
387
+ """Clear all history. Returns count deleted."""
388
+ cursor = self._conn.execute("DELETE FROM history")
389
+ self._conn.commit()
390
+ return cursor.rowcount
391
+
392
+ def get_most_played(self, limit: int = 10) -> list[HistoryEntry]:
393
+ """Get most played tracks."""
394
+ cursor = self._conn.execute(
395
+ """
396
+ SELECT h.*, t.video_id, t.title, t.channel, t.duration, t.thumbnail_url
397
+ FROM history h
398
+ JOIN tracks t ON t.id = h.track_id
399
+ ORDER BY h.play_count DESC
400
+ LIMIT ?
401
+ """,
402
+ (limit,),
403
+ )
404
+
405
+ entries = []
406
+ for row in cursor.fetchall():
407
+ track = Track(
408
+ id=row["track_id"],
409
+ video_id=row["video_id"],
410
+ title=row["title"],
411
+ channel=row["channel"],
412
+ duration=row["duration"],
413
+ thumbnail_url=row["thumbnail_url"],
414
+ )
415
+ entries.append(
416
+ HistoryEntry(
417
+ id=row["id"],
418
+ track=track,
419
+ played_at=datetime.fromisoformat(row["played_at"]),
420
+ play_count=row["play_count"],
421
+ last_position=row["last_position"],
422
+ completed=bool(row["completed"]),
423
+ )
424
+ )
425
+
426
+ return entries