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/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
|
wrkmon/data/__init__.py
ADDED
wrkmon/data/database.py
ADDED
|
@@ -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
|