kryten-webqueue 0.1.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.
Files changed (46) hide show
  1. kryten_webqueue/__init__.py +0 -0
  2. kryten_webqueue/__main__.py +10 -0
  3. kryten_webqueue/api_gate/__init__.py +0 -0
  4. kryten_webqueue/api_gate/client.py +113 -0
  5. kryten_webqueue/app.py +184 -0
  6. kryten_webqueue/auth/__init__.py +0 -0
  7. kryten_webqueue/auth/otp.py +10 -0
  8. kryten_webqueue/auth/rate_limit.py +29 -0
  9. kryten_webqueue/auth/session.py +40 -0
  10. kryten_webqueue/catalog/__init__.py +0 -0
  11. kryten_webqueue/catalog/db.py +562 -0
  12. kryten_webqueue/catalog/images.py +114 -0
  13. kryten_webqueue/catalog/sync.py +96 -0
  14. kryten_webqueue/config.py +46 -0
  15. kryten_webqueue/playlists/__init__.py +0 -0
  16. kryten_webqueue/playlists/fire.py +71 -0
  17. kryten_webqueue/playlists/importer.py +92 -0
  18. kryten_webqueue/playlists/scheduler.py +72 -0
  19. kryten_webqueue/queue/__init__.py +0 -0
  20. kryten_webqueue/queue/ordering.py +186 -0
  21. kryten_webqueue/queue/poller.py +43 -0
  22. kryten_webqueue/queue/shadow.py +116 -0
  23. kryten_webqueue/routes/__init__.py +0 -0
  24. kryten_webqueue/routes/admin_playlists.py +98 -0
  25. kryten_webqueue/routes/admin_queue.py +64 -0
  26. kryten_webqueue/routes/admin_schedules.py +129 -0
  27. kryten_webqueue/routes/auth.py +83 -0
  28. kryten_webqueue/routes/catalog.py +44 -0
  29. kryten_webqueue/routes/pages.py +82 -0
  30. kryten_webqueue/routes/queue.py +144 -0
  31. kryten_webqueue/routes/user.py +35 -0
  32. kryten_webqueue/static/css/main.css +470 -0
  33. kryten_webqueue/static/js/main.js +26 -0
  34. kryten_webqueue/templates/admin/index.html +98 -0
  35. kryten_webqueue/templates/auth/login.html +69 -0
  36. kryten_webqueue/templates/base.html +41 -0
  37. kryten_webqueue/templates/catalog/browse.html +105 -0
  38. kryten_webqueue/templates/queue/index.html +126 -0
  39. kryten_webqueue/templates/user/dashboard.html +87 -0
  40. kryten_webqueue/ws/__init__.py +0 -0
  41. kryten_webqueue/ws/handler.py +59 -0
  42. kryten_webqueue/ws/manager.py +57 -0
  43. kryten_webqueue-0.1.1.dist-info/METADATA +127 -0
  44. kryten_webqueue-0.1.1.dist-info/RECORD +46 -0
  45. kryten_webqueue-0.1.1.dist-info/WHEEL +4 -0
  46. kryten_webqueue-0.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,562 @@
1
+ import aiosqlite
2
+ from pathlib import Path
3
+ from datetime import datetime, UTC
4
+
5
+
6
+ MIGRATIONS = [
7
+ # v1: Migration tracking table
8
+ """
9
+ CREATE TABLE IF NOT EXISTS _migrations (
10
+ version INTEGER PRIMARY KEY,
11
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12
+ );
13
+ """,
14
+ # v2: Core schema
15
+ """
16
+ CREATE TABLE IF NOT EXISTS catalog (
17
+ friendly_token TEXT PRIMARY KEY,
18
+ title TEXT NOT NULL,
19
+ description TEXT,
20
+ duration_sec INTEGER,
21
+ manifest_url TEXT NOT NULL,
22
+ thumbnail_url TEXT,
23
+ cover_art_path TEXT,
24
+ cover_art_source TEXT,
25
+ added_at TIMESTAMP,
26
+ updated_at TIMESTAMP,
27
+ synced_at TIMESTAMP
28
+ );
29
+
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS catalog_fts USING fts5(
31
+ friendly_token UNINDEXED,
32
+ title,
33
+ description,
34
+ content='catalog',
35
+ content_rowid='rowid'
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS categories (
39
+ id INTEGER PRIMARY KEY,
40
+ name TEXT NOT NULL UNIQUE,
41
+ slug TEXT NOT NULL UNIQUE
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS catalog_categories (
45
+ friendly_token TEXT REFERENCES catalog(friendly_token) ON DELETE CASCADE,
46
+ category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
47
+ PRIMARY KEY (friendly_token, category_id)
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS tags (
51
+ id INTEGER PRIMARY KEY,
52
+ name TEXT NOT NULL UNIQUE
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS catalog_tags (
56
+ friendly_token TEXT REFERENCES catalog(friendly_token) ON DELETE CASCADE,
57
+ tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
58
+ PRIMARY KEY (friendly_token, tag_id)
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS sync_log (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ started_at TIMESTAMP NOT NULL,
64
+ ended_at TIMESTAMP,
65
+ items_seen INTEGER,
66
+ items_new INTEGER,
67
+ items_updated INTEGER,
68
+ errors INTEGER,
69
+ status TEXT
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS otps (
73
+ username TEXT NOT NULL,
74
+ code TEXT NOT NULL,
75
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
76
+ expires_at TIMESTAMP NOT NULL,
77
+ used BOOLEAN NOT NULL DEFAULT 0
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_otps_username ON otps(username);
80
+
81
+ CREATE TABLE IF NOT EXISTS queue_shadow (
82
+ uid INTEGER PRIMARY KEY,
83
+ position INTEGER NOT NULL,
84
+ title TEXT,
85
+ friendly_token TEXT,
86
+ media_type TEXT NOT NULL,
87
+ media_id TEXT NOT NULL,
88
+ duration_sec INTEGER,
89
+ is_pay BOOLEAN NOT NULL DEFAULT 0,
90
+ paid_by TEXT,
91
+ tier TEXT,
92
+ z_cost INTEGER,
93
+ schedule_id INTEGER,
94
+ estimated_start_at TIMESTAMP,
95
+ added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
96
+ );
97
+
98
+ CREATE TABLE IF NOT EXISTS saved_playlists (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ name TEXT NOT NULL,
101
+ description TEXT,
102
+ is_immutable BOOLEAN NOT NULL DEFAULT 0,
103
+ created_by TEXT NOT NULL,
104
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
105
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS saved_playlist_items (
109
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
110
+ playlist_id INTEGER NOT NULL REFERENCES saved_playlists(id) ON DELETE CASCADE,
111
+ position INTEGER NOT NULL,
112
+ media_type TEXT NOT NULL,
113
+ media_id TEXT NOT NULL,
114
+ title TEXT,
115
+ duration_sec INTEGER,
116
+ UNIQUE(playlist_id, position)
117
+ );
118
+
119
+ CREATE TABLE IF NOT EXISTS playlist_schedules (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ playlist_id INTEGER REFERENCES saved_playlists(id) ON DELETE SET NULL,
122
+ label TEXT NOT NULL,
123
+ fire_at TIMESTAMP NOT NULL,
124
+ is_recurring BOOLEAN DEFAULT 0,
125
+ rrule TEXT,
126
+ immutability_expires_at TIMESTAMP,
127
+ pre_fire_lock_minutes INTEGER DEFAULT 15,
128
+ fired_at TIMESTAMP,
129
+ is_active BOOLEAN DEFAULT 1,
130
+ created_by TEXT NOT NULL,
131
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
132
+ );
133
+
134
+ CREATE TABLE IF NOT EXISTS active_schedule (
135
+ id INTEGER PRIMARY KEY DEFAULT 1,
136
+ schedule_id INTEGER REFERENCES playlist_schedules(id),
137
+ playlist_id INTEGER REFERENCES saved_playlists(id),
138
+ is_immutable BOOLEAN NOT NULL DEFAULT 0,
139
+ started_at TIMESTAMP,
140
+ estimated_end_at TIMESTAMP
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS spend_requests (
144
+ request_id TEXT PRIMARY KEY,
145
+ username TEXT NOT NULL,
146
+ uid INTEGER,
147
+ friendly_token TEXT,
148
+ tier TEXT,
149
+ z_cost INTEGER,
150
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
151
+ refunded BOOLEAN DEFAULT 0,
152
+ refunded_at TIMESTAMP
153
+ );
154
+
155
+ CREATE TABLE IF NOT EXISTS queue_history (
156
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
157
+ username TEXT NOT NULL,
158
+ friendly_token TEXT,
159
+ title TEXT,
160
+ tier TEXT,
161
+ z_cost INTEGER,
162
+ queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
163
+ status TEXT DEFAULT 'queued'
164
+ );
165
+ CREATE INDEX IF NOT EXISTS idx_queue_history_user ON queue_history(username);
166
+ """,
167
+ ]
168
+
169
+
170
+ class Database:
171
+ """Async SQLite database wrapper."""
172
+
173
+ def __init__(self, db_path: str):
174
+ self._db_path = db_path
175
+ self._db: aiosqlite.Connection | None = None
176
+
177
+ async def connect(self):
178
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
179
+ self._db = await aiosqlite.connect(self._db_path)
180
+ self._db.row_factory = aiosqlite.Row
181
+ await self._db.execute("PRAGMA journal_mode=WAL")
182
+ await self._db.execute("PRAGMA foreign_keys=ON")
183
+
184
+ async def close(self):
185
+ if self._db:
186
+ await self._db.close()
187
+
188
+ async def run_migrations(self):
189
+ """Apply pending migrations sequentially."""
190
+ await self._executescript(MIGRATIONS[0])
191
+ row = await self._fetch_one("SELECT MAX(version) as v FROM _migrations")
192
+ current_version = (row["v"] or 0) if row else 0
193
+
194
+ for version, sql in enumerate(MIGRATIONS[1:], start=1):
195
+ if version > current_version:
196
+ await self._executescript(sql)
197
+ await self._execute("INSERT INTO _migrations (version) VALUES (?)", [version])
198
+
199
+ # --- Low-level helpers ---
200
+
201
+ async def _execute(self, sql: str, params: list | None = None):
202
+ await self._db.execute(sql, params or [])
203
+ await self._db.commit()
204
+
205
+ async def _executescript(self, sql: str):
206
+ await self._db.executescript(sql)
207
+ await self._db.commit()
208
+
209
+ async def _fetch_one(self, sql: str, params: list | None = None) -> dict | None:
210
+ cursor = await self._db.execute(sql, params or [])
211
+ row = await cursor.fetchone()
212
+ return dict(row) if row else None
213
+
214
+ async def _fetch_all(self, sql: str, params: list | None = None) -> list[dict]:
215
+ cursor = await self._db.execute(sql, params or [])
216
+ rows = await cursor.fetchall()
217
+ return [dict(r) for r in rows]
218
+
219
+ # --- Catalog ---
220
+
221
+ async def browse(self, *, category: str | None = None, page: int = 1, per_page: int = 24) -> list[dict]:
222
+ query = """
223
+ SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.manifest_url
224
+ FROM catalog c
225
+ WHERE c.friendly_token NOT IN (
226
+ SELECT spi.media_id FROM saved_playlist_items spi
227
+ JOIN saved_playlists sp ON spi.playlist_id = sp.id
228
+ WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
229
+ )
230
+ """
231
+ params: list = []
232
+ if category:
233
+ query += """
234
+ AND c.friendly_token IN (
235
+ SELECT cc.friendly_token FROM catalog_categories cc
236
+ JOIN categories cat ON cc.category_id = cat.id
237
+ WHERE cat.slug = ?
238
+ )
239
+ """
240
+ params.append(category)
241
+ query += " ORDER BY c.title ASC LIMIT ? OFFSET ?"
242
+ params.extend([per_page, (page - 1) * per_page])
243
+ return await self._fetch_all(query, params)
244
+
245
+ async def search(self, query_text: str, *, page: int = 1, per_page: int = 24) -> list[dict]:
246
+ sql = """
247
+ SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.manifest_url,
248
+ rank AS relevance
249
+ FROM catalog_fts fts
250
+ JOIN catalog c ON c.rowid = fts.rowid
251
+ WHERE catalog_fts MATCH ?
252
+ AND c.friendly_token NOT IN (
253
+ SELECT spi.media_id FROM saved_playlist_items spi
254
+ JOIN saved_playlists sp ON spi.playlist_id = sp.id
255
+ WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
256
+ )
257
+ ORDER BY rank
258
+ LIMIT ? OFFSET ?
259
+ """
260
+ return await self._fetch_all(sql, [query_text, per_page, (page - 1) * per_page])
261
+
262
+ async def get_item(self, friendly_token: str) -> dict | None:
263
+ sql = """
264
+ SELECT * FROM catalog
265
+ WHERE friendly_token = ?
266
+ AND friendly_token NOT IN (
267
+ SELECT spi.media_id FROM saved_playlist_items spi
268
+ JOIN saved_playlists sp ON spi.playlist_id = sp.id
269
+ WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
270
+ )
271
+ """
272
+ return await self._fetch_one(sql, [friendly_token])
273
+
274
+ async def get_item_admin(self, friendly_token: str) -> dict | None:
275
+ return await self._fetch_one("SELECT * FROM catalog WHERE friendly_token = ?", [friendly_token])
276
+
277
+ async def is_restricted(self, friendly_token: str) -> bool:
278
+ sql = """
279
+ SELECT 1 FROM saved_playlist_items spi
280
+ JOIN saved_playlists sp ON spi.playlist_id = sp.id
281
+ WHERE sp.is_immutable = 1
282
+ AND spi.media_type = 'cm'
283
+ AND spi.media_id = ?
284
+ LIMIT 1
285
+ """
286
+ row = await self._fetch_one(sql, [friendly_token])
287
+ return row is not None
288
+
289
+ async def get_categories(self) -> list[dict]:
290
+ return await self._fetch_all("SELECT id, name, slug FROM categories ORDER BY name")
291
+
292
+ async def insert_catalog(self, row: dict):
293
+ sql = """
294
+ INSERT INTO catalog (friendly_token, title, description, duration_sec,
295
+ manifest_url, thumbnail_url, synced_at)
296
+ VALUES (:friendly_token, :title, :description, :duration_sec,
297
+ :manifest_url, :thumbnail_url, :synced_at)
298
+ """
299
+ await self._db.execute(sql, row)
300
+ # Update FTS index
301
+ await self._db.execute(
302
+ "INSERT INTO catalog_fts(rowid, friendly_token, title, description) "
303
+ "SELECT rowid, friendly_token, title, description FROM catalog WHERE friendly_token = ?",
304
+ [row["friendly_token"]],
305
+ )
306
+ await self._db.commit()
307
+
308
+ async def update_catalog(self, friendly_token: str, row: dict):
309
+ sql = """
310
+ UPDATE catalog SET title=:title, description=:description,
311
+ duration_sec=:duration_sec, manifest_url=:manifest_url,
312
+ thumbnail_url=:thumbnail_url, synced_at=:synced_at, updated_at=:synced_at
313
+ WHERE friendly_token=:friendly_token
314
+ """
315
+ await self._db.execute(sql, row)
316
+ # Rebuild FTS for this row
317
+ await self._db.execute(
318
+ "DELETE FROM catalog_fts WHERE friendly_token = ?", [friendly_token]
319
+ )
320
+ await self._db.execute(
321
+ "INSERT INTO catalog_fts(rowid, friendly_token, title, description) "
322
+ "SELECT rowid, friendly_token, title, description FROM catalog WHERE friendly_token = ?",
323
+ [friendly_token],
324
+ )
325
+ await self._db.commit()
326
+
327
+ async def update_cover_art(self, friendly_token: str, path: str, source: str):
328
+ await self._execute(
329
+ "UPDATE catalog SET cover_art_path=?, cover_art_source=? WHERE friendly_token=?",
330
+ [path, source, friendly_token],
331
+ )
332
+
333
+ # --- Sync log ---
334
+
335
+ async def start_sync_log(self) -> int:
336
+ cursor = await self._db.execute(
337
+ "INSERT INTO sync_log (started_at, status) VALUES (?, 'running')",
338
+ [datetime.now(UTC).isoformat()],
339
+ )
340
+ await self._db.commit()
341
+ return cursor.lastrowid
342
+
343
+ async def finish_sync_log(self, log_id: int, stats: dict, status: str):
344
+ await self._execute(
345
+ "UPDATE sync_log SET ended_at=?, items_seen=?, items_new=?, items_updated=?, errors=?, status=? WHERE id=?",
346
+ [datetime.now(UTC).isoformat(), stats["seen"], stats["new"], stats["updated"], stats["errors"], status, log_id],
347
+ )
348
+
349
+ async def get_sync_logs(self, limit: int = 10) -> list[dict]:
350
+ return await self._fetch_all(
351
+ "SELECT * FROM sync_log ORDER BY id DESC LIMIT ?", [limit]
352
+ )
353
+
354
+ # --- OTP ---
355
+
356
+ async def store_otp(self, username: str, code: str, expires_at: str):
357
+ await self._execute(
358
+ "INSERT INTO otps (username, code, expires_at) VALUES (?, ?, ?)",
359
+ [username, code, expires_at],
360
+ )
361
+
362
+ async def verify_otp(self, username: str, code: str) -> bool:
363
+ row = await self._fetch_one(
364
+ "SELECT rowid FROM otps WHERE username=? AND code=? AND used=0 AND expires_at > datetime('now')",
365
+ [username, code],
366
+ )
367
+ if row:
368
+ await self._execute("UPDATE otps SET used=1 WHERE rowid=?", [row["rowid"]])
369
+ return True
370
+ return False
371
+
372
+ async def cleanup_expired_otps(self):
373
+ await self._execute("DELETE FROM otps WHERE expires_at < datetime('now') OR used=1")
374
+
375
+ # --- Queue shadow ---
376
+
377
+ async def get_shadow_items(self) -> list[dict]:
378
+ return await self._fetch_all("SELECT * FROM queue_shadow ORDER BY position ASC")
379
+
380
+ async def upsert_shadow_item(self, item: dict):
381
+ sql = """
382
+ INSERT OR REPLACE INTO queue_shadow
383
+ (uid, position, title, media_type, media_id, duration_sec, is_pay, paid_by, tier, z_cost, schedule_id, added_at)
384
+ VALUES (:uid, :position, :title, :media_type, :media_id, :duration_sec, :is_pay,
385
+ :paid_by, :tier, :z_cost, :schedule_id, :added_at)
386
+ """
387
+ defaults = {"paid_by": None, "tier": None, "z_cost": None, "schedule_id": None,
388
+ "friendly_token": None, "added_at": datetime.now(UTC).isoformat()}
389
+ row = {**defaults, **item}
390
+ await self._db.execute(sql, row)
391
+ await self._db.commit()
392
+
393
+ async def remove_shadow_items(self, uids: set[int]):
394
+ placeholders = ",".join("?" * len(uids))
395
+ await self._execute(f"DELETE FROM queue_shadow WHERE uid IN ({placeholders})", list(uids))
396
+
397
+ async def update_shadow_position(self, uid: int, position: int):
398
+ await self._db.execute("UPDATE queue_shadow SET position=? WHERE uid=?", [position, uid])
399
+ await self._db.commit()
400
+
401
+ async def update_shadow_estimated_start(self, uid: int, estimated: str):
402
+ await self._db.execute(
403
+ "UPDATE queue_shadow SET estimated_start_at=? WHERE uid=?", [estimated, uid]
404
+ )
405
+ await self._db.commit()
406
+
407
+ async def get_last_pay_uid(self) -> int | None:
408
+ row = await self._fetch_one(
409
+ "SELECT uid FROM queue_shadow WHERE is_pay = 1 ORDER BY position DESC LIMIT 1"
410
+ )
411
+ return row["uid"] if row else None
412
+
413
+ async def get_shadow_position_after(self, after_uid: int) -> int:
414
+ row = await self._fetch_one("SELECT position FROM queue_shadow WHERE uid = ?", [after_uid])
415
+ return (row["position"] + 1) if row else 0
416
+
417
+ async def get_pay_items(self) -> list[dict]:
418
+ return await self._fetch_all("SELECT * FROM queue_shadow WHERE is_pay = 1 ORDER BY position ASC")
419
+
420
+ # --- Spend requests ---
421
+
422
+ async def save_spend_request(self, request_id: str, *, username: str, uid: int | None,
423
+ friendly_token: str | None = None, tier: str | None = None,
424
+ z_cost: int | None = None):
425
+ sql = """
426
+ INSERT OR IGNORE INTO spend_requests (request_id, username, uid, friendly_token, tier, z_cost)
427
+ VALUES (?, ?, ?, ?, ?, ?)
428
+ """
429
+ await self._execute(sql, [request_id, username, uid, friendly_token, tier, z_cost])
430
+
431
+ async def get_request_id_for_uid(self, uid: int) -> str | None:
432
+ row = await self._fetch_one(
433
+ "SELECT request_id FROM spend_requests WHERE uid = ? AND refunded = 0 LIMIT 1", [uid]
434
+ )
435
+ return row["request_id"] if row else None
436
+
437
+ async def mark_spend_refunded(self, request_id: str):
438
+ await self._execute(
439
+ "UPDATE spend_requests SET refunded=1, refunded_at=datetime('now') WHERE request_id=?",
440
+ [request_id],
441
+ )
442
+
443
+ # --- Queue history ---
444
+
445
+ async def add_queue_history(self, *, username: str, friendly_token: str | None,
446
+ title: str | None, tier: str, z_cost: int):
447
+ await self._execute(
448
+ "INSERT INTO queue_history (username, friendly_token, title, tier, z_cost) VALUES (?, ?, ?, ?, ?)",
449
+ [username, friendly_token, title, tier, z_cost],
450
+ )
451
+
452
+ async def get_user_queue_history(self, username: str, limit: int = 50) -> list[dict]:
453
+ return await self._fetch_all(
454
+ "SELECT * FROM queue_history WHERE username=? ORDER BY id DESC LIMIT ?",
455
+ [username, limit],
456
+ )
457
+
458
+ # --- Saved playlists ---
459
+
460
+ async def get_saved_playlists(self) -> list[dict]:
461
+ return await self._fetch_all("SELECT * FROM saved_playlists ORDER BY name")
462
+
463
+ async def get_saved_playlist(self, playlist_id: int) -> dict | None:
464
+ return await self._fetch_one("SELECT * FROM saved_playlists WHERE id=?", [playlist_id])
465
+
466
+ async def create_saved_playlist(self, *, name: str, description: str | None, is_immutable: bool, created_by: str) -> int:
467
+ cursor = await self._db.execute(
468
+ "INSERT INTO saved_playlists (name, description, is_immutable, created_by) VALUES (?, ?, ?, ?)",
469
+ [name, description, int(is_immutable), created_by],
470
+ )
471
+ await self._db.commit()
472
+ return cursor.lastrowid
473
+
474
+ async def update_saved_playlist(self, playlist_id: int, *, name: str, description: str | None, is_immutable: bool):
475
+ await self._execute(
476
+ "UPDATE saved_playlists SET name=?, description=?, is_immutable=?, updated_at=datetime('now') WHERE id=?",
477
+ [name, description, int(is_immutable), playlist_id],
478
+ )
479
+
480
+ async def delete_saved_playlist(self, playlist_id: int):
481
+ await self._execute("DELETE FROM saved_playlists WHERE id=?", [playlist_id])
482
+
483
+ async def get_saved_playlist_items(self, playlist_id: int) -> list[dict]:
484
+ return await self._fetch_all(
485
+ "SELECT * FROM saved_playlist_items WHERE playlist_id=? ORDER BY position", [playlist_id]
486
+ )
487
+
488
+ async def replace_playlist_items(self, playlist_id: int, items: list[dict]):
489
+ await self._db.execute("DELETE FROM saved_playlist_items WHERE playlist_id=?", [playlist_id])
490
+ for i, item in enumerate(items):
491
+ await self._db.execute(
492
+ "INSERT INTO saved_playlist_items (playlist_id, position, media_type, media_id, title, duration_sec) "
493
+ "VALUES (?, ?, ?, ?, ?, ?)",
494
+ [playlist_id, i, item["media_type"], item["media_id"], item.get("title"), item.get("duration_sec")],
495
+ )
496
+ await self._db.commit()
497
+
498
+ # --- Schedules ---
499
+
500
+ async def get_schedules(self) -> list[dict]:
501
+ return await self._fetch_all("SELECT * FROM playlist_schedules ORDER BY fire_at")
502
+
503
+ async def get_schedule(self, schedule_id: int) -> dict | None:
504
+ return await self._fetch_one("SELECT * FROM playlist_schedules WHERE id=?", [schedule_id])
505
+
506
+ async def create_schedule(self, **kwargs) -> int:
507
+ keys = ", ".join(kwargs.keys())
508
+ placeholders = ", ".join("?" * len(kwargs))
509
+ cursor = await self._db.execute(
510
+ f"INSERT INTO playlist_schedules ({keys}) VALUES ({placeholders})",
511
+ list(kwargs.values()),
512
+ )
513
+ await self._db.commit()
514
+ return cursor.lastrowid
515
+
516
+ async def update_schedule(self, schedule_id: int, **kwargs):
517
+ sets = ", ".join(f"{k}=?" for k in kwargs.keys())
518
+ await self._execute(
519
+ f"UPDATE playlist_schedules SET {sets} WHERE id=?",
520
+ [*kwargs.values(), schedule_id],
521
+ )
522
+
523
+ async def delete_schedule(self, schedule_id: int):
524
+ await self._execute("DELETE FROM playlist_schedules WHERE id=?", [schedule_id])
525
+
526
+ async def mark_schedule_fired(self, schedule_id: int, fired_at: str):
527
+ await self._execute(
528
+ "UPDATE playlist_schedules SET fired_at=? WHERE id=?", [fired_at, schedule_id]
529
+ )
530
+
531
+ # --- Active schedule ---
532
+
533
+ async def get_active_schedule(self) -> dict | None:
534
+ return await self._fetch_one("SELECT * FROM active_schedule WHERE id=1")
535
+
536
+ async def set_active_schedule(self, *, schedule_id: int, playlist_id: int,
537
+ is_immutable: bool, started_at: str, estimated_end_at: str):
538
+ await self._execute(
539
+ "INSERT OR REPLACE INTO active_schedule (id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at) "
540
+ "VALUES (1, ?, ?, ?, ?, ?)",
541
+ [schedule_id, playlist_id, int(is_immutable), started_at, estimated_end_at],
542
+ )
543
+
544
+ async def clear_active_schedule(self):
545
+ await self._execute("DELETE FROM active_schedule WHERE id=1")
546
+
547
+ # --- Pre-fire lock check ---
548
+
549
+ async def is_pre_fire_lock_active(self) -> bool:
550
+ row = await self._fetch_one("""
551
+ SELECT 1 FROM playlist_schedules
552
+ WHERE is_active = 1
553
+ AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
554
+ AND fire_at > datetime('now')
555
+ LIMIT 1
556
+ """)
557
+ return row is not None
558
+
559
+ async def get_next_schedule(self) -> dict | None:
560
+ return await self._fetch_one(
561
+ "SELECT * FROM playlist_schedules WHERE is_active=1 AND fire_at > datetime('now') ORDER BY fire_at LIMIT 1"
562
+ )
@@ -0,0 +1,114 @@
1
+ import httpx
2
+ import logging
3
+ from pathlib import Path
4
+ from PIL import Image
5
+ import io
6
+ import hashlib
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class CoverArtResolver:
12
+ """Downloads and caches cover art for catalog items."""
13
+
14
+ WIDTHS = [200, 400, 800]
15
+
16
+ def __init__(self, *, image_dir: str, placeholder_dir: str,
17
+ tmdb_api_key: str = "", omdb_api_key: str = ""):
18
+ self._image_dir = Path(image_dir)
19
+ self._placeholder_dir = Path(placeholder_dir)
20
+ self._tmdb_key = tmdb_api_key
21
+ self._omdb_key = omdb_api_key
22
+ self._client = httpx.AsyncClient(timeout=15.0)
23
+ self._image_dir.mkdir(parents=True, exist_ok=True)
24
+ self._placeholder_dir.mkdir(parents=True, exist_ok=True)
25
+
26
+ async def close(self):
27
+ await self._client.aclose()
28
+
29
+ async def resolve(self, friendly_token: str, title: str, db) -> str | None:
30
+ """Try to fetch cover art; return relative path or None."""
31
+ # Check if already cached
32
+ existing = await db.get_item_admin(friendly_token)
33
+ if existing and existing.get("cover_art_path"):
34
+ return existing["cover_art_path"]
35
+
36
+ # Try TMDB first
37
+ image_url = None
38
+ source = None
39
+ if self._tmdb_key:
40
+ image_url = await self._search_tmdb(title)
41
+ source = "tmdb"
42
+ if not image_url and self._omdb_key:
43
+ image_url = await self._search_omdb(title)
44
+ source = "omdb"
45
+
46
+ if not image_url:
47
+ return None
48
+
49
+ # Download and generate responsive variants
50
+ try:
51
+ resp = await self._client.get(image_url)
52
+ if resp.status_code != 200:
53
+ return None
54
+ return await self._save_responsive(friendly_token, resp.content, source, db)
55
+ except Exception as e:
56
+ logger.warning(f"Failed to download cover art for {friendly_token}: {e}")
57
+ return None
58
+
59
+ async def _search_tmdb(self, title: str) -> str | None:
60
+ try:
61
+ resp = await self._client.get(
62
+ "https://api.themoviedb.org/3/search/multi",
63
+ params={"api_key": self._tmdb_key, "query": title},
64
+ )
65
+ if resp.status_code != 200:
66
+ return None
67
+ results = resp.json().get("results", [])
68
+ if results:
69
+ poster = results[0].get("poster_path")
70
+ if poster:
71
+ return f"https://image.tmdb.org/t/p/w500{poster}"
72
+ except Exception:
73
+ pass
74
+ return None
75
+
76
+ async def _search_omdb(self, title: str) -> str | None:
77
+ try:
78
+ resp = await self._client.get(
79
+ "https://www.omdbapi.com/",
80
+ params={"apikey": self._omdb_key, "t": title},
81
+ )
82
+ if resp.status_code != 200:
83
+ return None
84
+ data = resp.json()
85
+ poster = data.get("Poster")
86
+ if poster and poster != "N/A":
87
+ return poster
88
+ except Exception:
89
+ pass
90
+ return None
91
+
92
+ async def _save_responsive(self, friendly_token: str, data: bytes, source: str, db) -> str:
93
+ """Save image in multiple responsive widths."""
94
+ token_hash = hashlib.md5(friendly_token.encode()).hexdigest()[:8]
95
+ base_dir = self._image_dir / token_hash
96
+ base_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ img = Image.open(io.BytesIO(data))
99
+ if img.mode != "RGB":
100
+ img = img.convert("RGB")
101
+
102
+ for width in self.WIDTHS:
103
+ if img.width > width:
104
+ ratio = width / img.width
105
+ height = int(img.height * ratio)
106
+ resized = img.resize((width, height), Image.LANCZOS)
107
+ else:
108
+ resized = img.copy()
109
+ out_path = base_dir / f"{width}.webp"
110
+ resized.save(out_path, "WEBP", quality=80)
111
+
112
+ relative_path = f"{token_hash}"
113
+ await db.update_cover_art(friendly_token, relative_path, source)
114
+ return relative_path